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:
Daniel 2026-04-23 23:35:05 +02:00
parent 42d951b45e
commit 520a1f8fb1
9 changed files with 560 additions and 55 deletions

View file

@ -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>
);
}

View file

@ -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;

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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;