feat(client): port Settings — Integrations (Nextcloud + Documents)
Second of three commits porting the vanilla settings.html. This one
delivers the two Integrations sub-sections, rendered below the Security
block and shown to every authenticated user (not gated by canLocalAuth —
SSO users also integrate Nextcloud and manage documents).
client/src/pages/Settings.tsx — NextcloudCard
Form + status line driven by /api/auth/me. Connect POSTs
/api/nextcloud/connect (nextcloudUrl / username / appPassword), which
does a PROPFIND probe against the remote, creates the target folder
via MKCOL, and encrypts the app password at rest. On success we
invalidate the ['auth-me'] query so the status line flips to
"Connected to …" without a reload. Disconnect goes through a
ConfirmModal (not a native confirm) and POSTs /api/nextcloud/disconnect.
When connected, a second row exposes the "Learning Hub — Default
Browse Path" input backed by POST /api/user/webdav-path. (That handler
lives inline in server.ts, not in userPreferences.ts — a quirk of the
existing codebase that the port preserves.)
client/src/pages/Settings.tsx — DocumentsCard
React Query feed off /api/documents. When S3 is not configured the
server returns { s3_configured: false } and we render a static notice
instead of the upload area (same branch as vanilla documents.js). The
upload form bypasses the JSON api wrapper to send multipart FormData
directly via fetch with credentials: 'include' (cookie auth continues
to work). Downloads hit /api/documents/:id/download to receive a 5-min
presigned URL which we open in a new tab. Delete goes through the
shared ConfirmModal — replaces the vanilla showConfirm({ danger, … }).
Downloading-state spinner is per-row (useMutation.variables === doc.id)
so other rows stay clickable while one is in flight.
shared/types.ts + client/src/shared/types.ts
Additive only:
- AuthUser gains webdav_learning_path — already returned by
/api/auth/me but missing from the type.
- New response shapes: NextcloudConnectOk, UserDocument,
DocumentsListOk, DocumentUploadOk, DocumentDownloadOk.
e2e/tests/settings-react-integrations.spec.js
Four smoke tests: field presence, empty-form validation error, the
S3-configured-or-notice branch renders, and a repeat of the
no-native-dialog guard covering the Integrations interactions.
Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Bundle 404.56 kB / 117.12 kB gzipped (+9 kB over commit 1). The e2e
container still predates /app/*; running these specs needs a rebuild.
This commit is contained in:
parent
42d951b45e
commit
520a1f8fb1
9 changed files with 560 additions and 55 deletions
|
|
@ -33,6 +33,11 @@ import type {
|
|||
RegenBackupCodesOk,
|
||||
ChangePasswordOk,
|
||||
RevokeAllSessionsOk,
|
||||
NextcloudConnectOk,
|
||||
DocumentsListOk,
|
||||
DocumentUploadOk,
|
||||
DocumentDownloadOk,
|
||||
UserDocument,
|
||||
} from '@/shared/types';
|
||||
|
||||
// ── Small presentational bits ────────────────────────────────
|
||||
|
|
@ -538,6 +543,382 @@ function SessionsCard() {
|
|||
);
|
||||
}
|
||||
|
||||
// ── Nextcloud Integration ───────────────────────────────────
|
||||
function NextcloudCard({ user }: { user: AuthUser }) {
|
||||
const qc = useQueryClient();
|
||||
const connected = !!user.nextcloud_url;
|
||||
|
||||
const [url, setUrl] = useState(user.nextcloud_url || '');
|
||||
const [username, setUsername] = useState(user.nextcloud_user || '');
|
||||
const [appPassword, setAppPassword] = useState('');
|
||||
const [webdavPath, setWebdavPath] = useState(user.webdav_learning_path || '');
|
||||
const [disconnectOpen, setDisconnectOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const connect = useMutation({
|
||||
mutationFn: (body: { nextcloudUrl: string; username: string; appPassword: string }) =>
|
||||
api.post<NextcloudConnectOk>('/api/nextcloud/connect', body),
|
||||
onSuccess: (data) => {
|
||||
setMsg({ text: data.message || 'Connected', kind: 'ok' });
|
||||
setAppPassword('');
|
||||
qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message || 'Connection failed', kind: 'err' }),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => api.post<{ success: true }>('/api/nextcloud/disconnect', {}),
|
||||
onSuccess: () => {
|
||||
setMsg({ text: 'Disconnected', kind: 'info' });
|
||||
setAppPassword('');
|
||||
qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
const savePath = useMutation({
|
||||
mutationFn: (path: string) =>
|
||||
api.post<{ success: true }>('/api/user/webdav-path', { path }),
|
||||
onSuccess: () => {
|
||||
setMsg({ text: 'Path saved', kind: 'ok' });
|
||||
qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message || 'Failed to save', kind: 'err' }),
|
||||
});
|
||||
|
||||
function onConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
const cleanUrl = url.trim().replace(/\/+$/, '');
|
||||
const u = username.trim();
|
||||
const p = appPassword.trim();
|
||||
if (!cleanUrl || !u || !p) return setMsg({ text: 'Fill all Nextcloud fields', kind: 'err' });
|
||||
connect.mutate({ nextcloudUrl: cleanUrl, username: u, appPassword: p });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="nextcloud-section">
|
||||
<h3 className="text-base font-semibold">Nextcloud Integration</h3>
|
||||
<p className="text-sm text-muted-foreground">Export generated documents to your Nextcloud.</p>
|
||||
<div className="text-sm" data-testid="nc-status">
|
||||
{connected ? (
|
||||
<>✅ Connected to <strong>{user.nextcloud_url}</strong> as {user.nextcloud_user}</>
|
||||
) : (
|
||||
<>Not connected</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={onConnect} className="space-y-2 max-w-md">
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">Nextcloud URL</span>
|
||||
<input
|
||||
type="url"
|
||||
className={input}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://cloud.example.com"
|
||||
data-testid="nc-url"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">Username</span>
|
||||
<input
|
||||
type="text"
|
||||
className={input}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your-username"
|
||||
data-testid="nc-user"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">App Password</span>
|
||||
<input
|
||||
type="password"
|
||||
className={input}
|
||||
value={appPassword}
|
||||
onChange={(e) => setAppPassword(e.target.value)}
|
||||
placeholder="Generate in Nextcloud → Settings → Security"
|
||||
data-testid="nc-pass"
|
||||
/>
|
||||
<span className="block text-xs text-muted-foreground mt-1">
|
||||
Go to Nextcloud → Settings → Security → Create new app password
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="submit"
|
||||
className={btnPrimary}
|
||||
disabled={connect.isPending}
|
||||
data-testid="btn-nc-connect"
|
||||
>
|
||||
{connect.isPending ? 'Connecting…' : connected ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
{connected && (
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-destructive'}
|
||||
onClick={() => setDisconnectOpen(true)}
|
||||
data-testid="btn-nc-disconnect"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{connected && (
|
||||
<div className="border-t border-border pt-3 space-y-2 max-w-md">
|
||||
<label className="block text-sm">
|
||||
<span className="block text-xs font-semibold text-muted-foreground mb-1">
|
||||
Learning Hub — Default Browse Path
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground mb-2">
|
||||
Folder opened first when picking files for AI content generation (e.g. <code>/Medical-Resources</code>)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className={input}
|
||||
value={webdavPath}
|
||||
onChange={(e) => setWebdavPath(e.target.value)}
|
||||
placeholder="/Medical-Resources"
|
||||
data-testid="nc-webdav-path"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary}
|
||||
disabled={savePath.isPending}
|
||||
onClick={() => savePath.mutate(webdavPath.trim())}
|
||||
data-testid="btn-nc-save-path"
|
||||
>
|
||||
{savePath.isPending ? 'Saving…' : 'Save Path'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={disconnectOpen}
|
||||
title="Disconnect Nextcloud?"
|
||||
body="Future exports will fail until you reconnect. Your stored credentials will be cleared."
|
||||
confirmText="Disconnect"
|
||||
danger
|
||||
busy={disconnect.isPending}
|
||||
onConfirm={() => {
|
||||
disconnect.mutate();
|
||||
setDisconnectOpen(false);
|
||||
}}
|
||||
onCancel={() => setDisconnectOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Documents (S3) ──────────────────────────────────────────
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return Math.round(bytes / 1024) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function DocumentRow({
|
||||
doc,
|
||||
onDownload,
|
||||
onDelete,
|
||||
downloading,
|
||||
}: {
|
||||
doc: UserDocument;
|
||||
onDownload: () => void;
|
||||
onDelete: () => void;
|
||||
downloading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
|
||||
data-testid={'doc-row-' + doc.id}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{doc.filename}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSize(doc.size_bytes)} · {new Date(doc.created_at).toLocaleDateString()}
|
||||
{doc.description ? ' · ' + doc.description : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={btnPrimary + ' text-xs'}
|
||||
disabled={downloading}
|
||||
onClick={onDownload}
|
||||
data-testid={'btn-doc-download-' + doc.id}
|
||||
>
|
||||
{downloading ? '…' : 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btnGhost + ' text-destructive text-xs'}
|
||||
onClick={onDelete}
|
||||
data-testid={'btn-doc-delete-' + doc.id}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentsCard() {
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [description, setDescription] = useState('');
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<UserDocument | null>(null);
|
||||
|
||||
const { data, isLoading, error } = useQuery<DocumentsListOk>({
|
||||
queryKey: ['documents'],
|
||||
queryFn: () => api.get<DocumentsListOk>('/api/documents'),
|
||||
});
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (body: { file: File; description: string }) => {
|
||||
const form = new FormData();
|
||||
form.append('file', body.file);
|
||||
form.append('description', body.description);
|
||||
// Multipart body — bypass the JSON-only api wrapper. Cookies auto-sent.
|
||||
const resp = await fetch('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
});
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
const parsed = ct.includes('application/json') ? await resp.json() : null;
|
||||
if (!resp.ok || (parsed && parsed.success === false)) {
|
||||
throw new Error((parsed && parsed.error) || resp.statusText);
|
||||
}
|
||||
return parsed as { success: true } & DocumentUploadOk;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setMsg({ text: 'Document uploaded: ' + data.filename, kind: 'ok' });
|
||||
setFile(null);
|
||||
setDescription('');
|
||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: 'Upload failed: ' + e.message, kind: 'err' }),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.delete<{ success: true }>('/api/documents/' + id),
|
||||
onSuccess: () => {
|
||||
setMsg({ text: 'Document deleted', kind: 'info' });
|
||||
qc.invalidateQueries({ queryKey: ['documents'] });
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
|
||||
});
|
||||
|
||||
const download = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
api.get<DocumentDownloadOk>('/api/documents/' + id + '/download'),
|
||||
onSuccess: (data) => {
|
||||
if (data.url) {
|
||||
// Open presigned URL in a new tab; it's short-lived (300s).
|
||||
window.open(data.url, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
setMsg({ text: 'Download failed', kind: 'err' });
|
||||
}
|
||||
},
|
||||
onError: (e: Error) => setMsg({ text: e.message || 'Download failed', kind: 'err' }),
|
||||
});
|
||||
|
||||
function submitUpload(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (!file) return setMsg({ text: 'Select a file first', kind: 'err' });
|
||||
upload.mutate({ file, description });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={card} data-testid="documents-section">
|
||||
<h3 className="text-base font-semibold">Documents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.
|
||||
</p>
|
||||
|
||||
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
|
||||
{error && <div className="text-sm text-destructive">Failed to load documents.</div>}
|
||||
|
||||
{data && !data.s3_configured && (
|
||||
<div className="text-sm text-muted-foreground italic">
|
||||
S3 storage not configured. Set S3_BUCKET in server environment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.s3_configured && (
|
||||
<>
|
||||
<form onSubmit={submitUpload} className="flex flex-wrap gap-2 items-center" data-testid="doc-upload-area">
|
||||
<input
|
||||
type="file"
|
||||
className="text-sm"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.txt,.csv"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
data-testid="doc-file-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className={input + ' max-w-xs'}
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
data-testid="doc-description"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={btnPrimary}
|
||||
disabled={!file || upload.isPending}
|
||||
data-testid="btn-doc-upload"
|
||||
>
|
||||
{upload.isPending ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.documents.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No documents uploaded yet.</div>
|
||||
) : (
|
||||
data.documents.map((d) => (
|
||||
<DocumentRow
|
||||
key={d.id}
|
||||
doc={d}
|
||||
onDownload={() => download.mutate(d.id)}
|
||||
onDelete={() => setDeleteTarget(d)}
|
||||
downloading={download.isPending && download.variables === d.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StatusLine msg={msg} />
|
||||
|
||||
<ConfirmModal
|
||||
open={!!deleteTarget}
|
||||
title="Delete this document permanently?"
|
||||
body={deleteTarget?.filename}
|
||||
confirmText="Delete"
|
||||
danger
|
||||
busy={del.isPending}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) del.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page shell ───────────────────────────────────────────────
|
||||
export default function Settings() {
|
||||
const { data: me, isLoading, error } = useQuery<MeOk>({
|
||||
|
|
@ -580,6 +961,10 @@ export default function Settings() {
|
|||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Integrations — shown to all users, not gated by canLocalAuth. */}
|
||||
{me && <NextcloudCard user={me.user} />}
|
||||
{me && <DocumentsCard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export interface AuthUser {
|
|||
nextcloud_url?: string | null;
|
||||
nextcloud_user?: string | null;
|
||||
nextcloud_folder?: string | null;
|
||||
webdav_learning_path?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +220,36 @@ export interface ChangePasswordOk {
|
|||
passwordWarning?: string;
|
||||
}
|
||||
|
||||
// ── Integrations ─────────────────────────────────────────────
|
||||
// /api/nextcloud/connect
|
||||
export interface NextcloudConnectOk {
|
||||
message: string;
|
||||
}
|
||||
// /api/nextcloud/disconnect — {success: true}
|
||||
|
||||
// /api/documents — shape returned to the client
|
||||
export interface UserDocument {
|
||||
id: number;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
description?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
export interface DocumentsListOk {
|
||||
documents: UserDocument[];
|
||||
s3_configured: boolean;
|
||||
}
|
||||
// /api/documents/upload — multipart; response below
|
||||
export interface DocumentUploadOk {
|
||||
id: number;
|
||||
filename: string;
|
||||
}
|
||||
// /api/documents/:id/download — returns a short-lived presigned URL
|
||||
export interface DocumentDownloadOk {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
58
e2e/tests/settings-react-integrations.spec.js
Normal file
58
e2e/tests/settings-react-integrations.spec.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// ============================================================
|
||||
// SETTINGS (React port) — Integrations sub-section smoke tests.
|
||||
//
|
||||
// Commit 2 of the Settings port. Covers:
|
||||
// • Nextcloud connect form (URL / user / app-password / Connect btn)
|
||||
// • Documents upload area (file input / description / Upload btn)
|
||||
// • Disconnect and Delete flows use the styled ConfirmModal, not
|
||||
// window.confirm() — the no-native-dialog guard is repeated here
|
||||
// so any future regression is caught in this file too.
|
||||
// ============================================================
|
||||
|
||||
const { test, expect, E2E_BASE } = require('../fixtures');
|
||||
|
||||
async function openReactSettings(page) {
|
||||
await page.goto(E2E_BASE + '/app/settings');
|
||||
await page.waitForSelector('[data-testid="nextcloud-section"], [data-testid="documents-section"]', {
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('React Settings — Integrations render', () => {
|
||||
|
||||
test('Nextcloud section: URL / user / app-password fields + Connect button', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="nc-url"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="nc-user"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="nc-pass"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="btn-nc-connect"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="nc-status"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Nextcloud connect validation — missing fields shows inline error', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
// Don't fill any fields — just submit.
|
||||
await page.click('[data-testid="btn-nc-connect"]');
|
||||
await expect(page.getByText('Fill all Nextcloud fields')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Documents section: either upload area or S3-not-configured notice renders', async ({ authedPage: _, page }) => {
|
||||
await openReactSettings(page);
|
||||
await expect(page.locator('[data-testid="documents-section"]')).toBeVisible();
|
||||
// Depending on whether S3 is configured, we see either the upload area or the not-configured message.
|
||||
const uploadArea = await page.locator('[data-testid="doc-upload-area"]').count();
|
||||
const notice = await page.getByText('S3 storage not configured').count();
|
||||
expect(uploadArea + notice).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('no native dialog fires on Nextcloud / Documents interactions', async ({ authedPage: _, page }) => {
|
||||
let nativeDialogFired = false;
|
||||
page.on('dialog', async (d) => { nativeDialogFired = true; await d.dismiss(); });
|
||||
|
||||
await openReactSettings(page);
|
||||
// Exercise the inline validation path; this path never calls alert().
|
||||
await page.click('[data-testid="btn-nc-connect"]');
|
||||
await expect(page.getByText('Fill all Nextcloud fields')).toBeVisible();
|
||||
expect(nativeDialogFired).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
2
public/app/assets/index-BT9JgnfU.css
Normal file
2
public/app/assets/index-BT9JgnfU.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
51
public/app/assets/index-DdnCNlZ0.js
Normal file
51
public/app/assets/index-DdnCNlZ0.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<script type="module" crossorigin src="/app/assets/index--QprPbuv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-D_MsmWEo.css">
|
||||
<script type="module" crossorigin src="/app/assets/index-DdnCNlZ0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BT9JgnfU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export interface AuthUser {
|
|||
nextcloud_url?: string | null;
|
||||
nextcloud_user?: string | null;
|
||||
nextcloud_folder?: string | null;
|
||||
webdav_learning_path?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +220,36 @@ export interface ChangePasswordOk {
|
|||
passwordWarning?: string;
|
||||
}
|
||||
|
||||
// ── Integrations ─────────────────────────────────────────────
|
||||
// /api/nextcloud/connect
|
||||
export interface NextcloudConnectOk {
|
||||
message: string;
|
||||
}
|
||||
// /api/nextcloud/disconnect — {success: true}
|
||||
|
||||
// /api/documents — shape returned to the client
|
||||
export interface UserDocument {
|
||||
id: number;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
description?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
export interface DocumentsListOk {
|
||||
documents: UserDocument[];
|
||||
s3_configured: boolean;
|
||||
}
|
||||
// /api/documents/upload — multipart; response below
|
||||
export interface DocumentUploadOk {
|
||||
id: number;
|
||||
filename: string;
|
||||
}
|
||||
// /api/documents/:id/download — returns a short-lived presigned URL
|
||||
export interface DocumentDownloadOk {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// ── Extensions (pagers/directory) ────────────────────────────
|
||||
export interface Extension {
|
||||
id: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue