pediatric-ai-scribe-v3/client/src/pages/Settings.tsx
Daniel 719d0cb7f7 feat(client): port Settings — Voice + Content (8 sub-sections)
Third and final commit of the Settings port. Adds the remaining eight
sub-sections so the React page matches vanilla settings.html 1:1.
After this commit Settings is fully ported; Layout already flipped to
available in commit 1, and the page fills out cleanly for local-auth
and SSO users alike.

Voice Preferences (VoicePreferencesCard)
  GET /api/user/preferences + /api/user/preferences/options populate the
  STT model / TTS voice selectors. Save POSTs /api/user/preferences.
  Preview persists the current TTS selection, then fetches /api/text-to-
  speech (binary blob, bypasses the JSON api wrapper), wraps the blob in
  an Audio element and plays it. A one-shot hydrated flag drives the
  first selection sync; after that the fields are local state.

Browser Whisper (BrowserWhisperCard) — UI-only port
  Persists the enabled flag + model choice under the same localStorage
  keys the vanilla BrowserWhisper module reads, so behavior will light
  up automatically when the recording components port. The preload +
  WASM transcription flow stays in vanilla for this commit — noted in
  the page copy so users aren't surprised.

Web Speech Recognition (WebSpeechCard) — UI-only port
  Same localStorage approach. Enabling surfaces a styled ConfirmModal
  with the HIPAA privacy warning before persisting. Enabling Web Speech
  flips Browser Whisper off automatically (mirrors vanilla priority:
  Web Speech > Browser Whisper > server).

My Templates (TemplatesCard)
  Full Memories CRUD for non-correction entries: category select,
  name, content textarea, Add/Update toggle (in-place edit), per-row
  Delete confirm. Hits /api/memories {GET, POST, PUT, DELETE}.

AI Corrections (CorrectionsCard)
  Read-only list filtered to category starting with 'correction_'.
  Per-row expand reveals the parsed ORIGINAL / CORRECTED TO: split
  (same text delimiter the vanilla parseCorrection() uses). Delete is
  wired through /api/memories/:id.

Audio Backups (AudioBackupsCard)
  Lists /api/audio-backups (server-stored, 24h TTL). Play opens the
  decompressed audio stream in a new tab; Delete hits DELETE
  /api/audio-backups/:id. Retry flow stays in vanilla for this commit —
  it re-submits to /api/transcribe and that integration belongs with
  the recording components.

Saved Encounters (SavedEncountersCard)
  Lists /api/encounters/saved with label / type / expires / preview.
  Delete only — Resume requires the encounter pages to receive
  pre-filled state, which ports alongside those pages.

Compliance (ComplianceCard)
  Static info card — plain JSX, no API.

shared/types.ts + client/src/shared/types.ts — additive only:
  UserPreferencesOk, PreferencesOptionsOk, VoiceOption,
  SavedEncounterRow, SavedEncountersListOk, AudioBackupRow,
  AudioBackupsListOk, MemoryRow, MemoriesOk.

e2e/tests/settings-react-voice-content.spec.js — seven smoke tests
covering control presence, templates empty-save validation, and the
Web Speech privacy-confirm modal (with the no-native-dialog guard).

Client tsc -b, server tsc --noEmit, and vite build all pass locally.
Final bundle 425.42 kB / 121.55 kB gzipped (+21 kB over commit 2).
The e2e container still predates /app/*; running these specs against
it needs a rebuild.
2026-04-23 23:40:33 +02:00

1789 lines
65 KiB
TypeScript

// ============================================================
// SETTINGS — account security, integrations, personal templates.
//
// This is the first of three commits porting the vanilla
// settings.html (13 sub-sections). Commit 1 covers the Settings
// shell + the three Security sub-sections:
// • Change Password — /api/auth/change-password
// • Two-Factor Auth — /api/auth/{setup,verify,disable}-2fa
// + /api/auth/2fa/backup-codes/count
// + /api/auth/2fa/backup-codes (regen)
// • Active Sessions — /api/sessions (list, revoke one, revoke all)
//
// Gating: Security sub-sections only render for users with a real
// local password (canLocalAuth === true). SSO-only users see a brief
// notice instead — password/2FA for them live in the IdP.
//
// Integrations (Nextcloud, Documents) and Voice + Content sections
// land in follow-up commits.
// ============================================================
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import ConfirmModal from '@/components/ConfirmModal';
import type {
MeOk,
AuthUser,
SessionsOk,
SessionRow,
Setup2faOk,
Verify2faOk,
BackupCodesCountOk,
RegenBackupCodesOk,
ChangePasswordOk,
RevokeAllSessionsOk,
NextcloudConnectOk,
DocumentsListOk,
DocumentUploadOk,
DocumentDownloadOk,
UserDocument,
UserPreferencesOk,
PreferencesOptionsOk,
VoiceOption,
SavedEncountersListOk,
SavedEncounterRow,
AudioBackupsListOk,
AudioBackupRow,
MemoriesOk,
MemoryRow,
} from '@/shared/types';
// ── Small presentational bits ────────────────────────────────
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
const btnGhost = 'rounded-md border border-border px-3 py-2 text-sm disabled:opacity-50';
const btnDanger = 'rounded-md bg-destructive text-white px-3 py-2 text-sm font-medium disabled:opacity-50';
type Msg = { text: string; kind: 'ok' | 'err' | 'info' } | null;
function StatusLine({ msg }: { msg: Msg }) {
if (!msg) return null;
const color =
msg.kind === 'ok' ? 'text-green-600'
: msg.kind === 'err' ? 'text-destructive'
: 'text-muted-foreground';
return <div className={'text-sm ' + color}>{msg.text}</div>;
}
function timeAgo(iso: string): string {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
// ── Change Password ─────────────────────────────────────────
function ChangePasswordCard() {
const qc = useQueryClient();
const [current, setCurrent] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [msg, setMsg] = useState<Msg>(null);
const mutation = useMutation({
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
api.post<ChangePasswordOk>('/api/auth/change-password', body),
onSuccess: (data) => {
setMsg({ text: data.message || 'Password changed', kind: 'ok' });
setCurrent('');
setNewPw('');
setConfirmPw('');
// Server destroys all other sessions on success — refresh the list.
qc.invalidateQueries({ queryKey: ['sessions'] });
if (data.passwordWarning) {
setTimeout(() => setMsg({ text: data.passwordWarning!, kind: 'info' }), 2000);
}
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!current || !newPw) return setMsg({ text: 'Fill in all fields', kind: 'err' });
if (newPw.length < 8) return setMsg({ text: 'New password must be 8+ characters', kind: 'err' });
if (newPw !== confirmPw) return setMsg({ text: 'Passwords do not match', kind: 'err' });
mutation.mutate({ currentPassword: current, newPassword: newPw });
}
return (
<section className={card} data-testid="change-password-section">
<h3 className="text-base font-semibold">Change Password</h3>
<form onSubmit={submit} className="space-y-2 max-w-sm">
<input
type="password"
className={input}
placeholder="Current password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
data-testid="pw-current"
/>
<input
type="password"
className={input}
placeholder="New password (8+ characters)"
minLength={8}
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
data-testid="pw-new"
/>
<input
type="password"
className={input}
placeholder="Confirm new password"
minLength={8}
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
data-testid="pw-confirm"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={mutation.isPending}
className={btnPrimary}
data-testid="btn-change-password"
>
{mutation.isPending ? 'Changing…' : 'Change Password'}
</button>
<StatusLine msg={msg} />
</div>
</form>
</section>
);
}
// ── Backup codes modal — shown once after first 2FA enable ──
function BackupCodesDisplay({ codes, onClose }: { codes: string[]; onClose: () => void }) {
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-md rounded-lg border border-border bg-background p-5 shadow-lg space-y-3">
<h3 className="text-base font-semibold">Save your backup codes</h3>
<p className="text-sm text-muted-foreground">
Each code can be used once if you lose access to your authenticator. They will not be shown again.
</p>
<pre className="bg-muted p-3 rounded text-sm font-mono whitespace-pre-wrap break-all">
{codes.join('\n')}
</pre>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => navigator.clipboard?.writeText(codes.join('\n'))}
className={btnGhost}
>
Copy
</button>
<button type="button" onClick={onClose} className={btnPrimary}>
I've saved them
</button>
</div>
</div>
</div>
);
}
// ── Two-Factor Authentication ───────────────────────────────
function TwoFactorCard({ user }: { user: AuthUser }) {
const qc = useQueryClient();
const enabled = user.totp_enabled === true;
const [phase, setPhase] = useState<'idle' | 'setup' | 'disabling'>('idle');
const [qr, setQr] = useState<Setup2faOk | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [disablePw, setDisablePw] = useState('');
const [shownBackupCodes, setShownBackupCodes] = useState<string[] | null>(null);
const [regenOpen, setRegenOpen] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const { data: backupCount } = useQuery<BackupCodesCountOk>({
queryKey: ['2fa-backup-count'],
queryFn: () => api.get<BackupCodesCountOk>('/api/auth/2fa/backup-codes/count'),
enabled,
});
const setupMutation = useMutation({
mutationFn: () => api.post<Setup2faOk>('/api/auth/setup-2fa', {}),
onSuccess: (data) => {
setQr(data);
setPhase('setup');
setMsg(null);
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const verifyMutation = useMutation({
mutationFn: (code: string) =>
api.post<Verify2faOk>('/api/auth/verify-2fa', { code }),
onSuccess: (data) => {
setPhase('idle');
setVerifyCode('');
setQr(null);
if (data.backupCodes && data.backupCodes.length) {
setShownBackupCodes(data.backupCodes);
}
qc.invalidateQueries({ queryKey: ['auth-me'] });
qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
setMsg({ text: '2FA enabled', kind: 'ok' });
},
onError: (e: Error) => setMsg({ text: e.message || 'Invalid code', kind: 'err' }),
});
const disableMutation = useMutation({
mutationFn: (password: string) =>
api.post<{ success: true }>('/api/auth/disable-2fa', { password }),
onSuccess: () => {
setPhase('idle');
setDisablePw('');
qc.invalidateQueries({ queryKey: ['auth-me'] });
qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
setMsg({ text: '2FA disabled', kind: 'info' });
},
onError: (e: Error) => setMsg({ text: e.message || 'Failed', kind: 'err' }),
});
const regenMutation = useMutation({
mutationFn: (password: string) =>
api.post<RegenBackupCodesOk>('/api/auth/2fa/backup-codes', { password }),
onSuccess: (data) => {
setRegenOpen(false);
if (data.codes && data.codes.length) setShownBackupCodes(data.codes);
qc.invalidateQueries({ queryKey: ['2fa-backup-count'] });
},
onError: (e: Error) => {
setRegenOpen(false);
setMsg({ text: e.message || 'Failed to regenerate codes', kind: 'err' });
},
});
return (
<section className={card} data-testid="2fa-section">
<h3 className="text-base font-semibold">Two-Factor Authentication</h3>
<p className="text-sm" data-testid="2fa-status">
Status:{' '}
<span className={enabled ? 'text-green-600 font-medium' : 'text-destructive font-medium'}>
{enabled ? ' Enabled' : ' Not enabled'}
</span>
</p>
{!enabled && phase === 'idle' && (
<button
type="button"
className={btnPrimary}
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
data-testid="btn-setup-2fa"
>
{setupMutation.isPending ? 'Setting up' : 'Enable 2FA'}
</button>
)}
{enabled && phase === 'idle' && (
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
className={btnGhost + ' text-destructive'}
onClick={() => setPhase('disabling')}
data-testid="btn-disable-2fa"
>
Disable 2FA
</button>
{backupCount && (
<span className={'text-sm ' + (backupCount.remaining <= 2 ? 'text-orange-500' : 'text-muted-foreground')}>
{backupCount.remaining} backup codes remaining.
</span>
)}
{backupCount && (
<button
type="button"
className={btnGhost}
onClick={() => setRegenOpen(true)}
data-testid="btn-regen-backup-codes"
>
Regenerate
</button>
)}
</div>
)}
{phase === 'setup' && qr && (
<div className="space-y-3 p-3 bg-muted/40 rounded-md">
<p className="text-sm">Scan this QR code with your authenticator app:</p>
<img src={qr.qrCode} alt="2FA QR code" className="bg-white p-2 rounded max-w-[240px]" />
<p className="text-sm">
Or enter manually: <code className="bg-muted px-2 py-0.5 rounded text-xs">{qr.secret}</code>
</p>
<div className="flex items-center gap-2 flex-wrap">
<input
type="text"
maxLength={6}
className={input + ' max-w-[140px] font-mono tracking-widest'}
placeholder="123456"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, ''))}
data-testid="2fa-verify-code"
/>
<button
type="button"
className={btnPrimary}
disabled={verifyCode.length !== 6 || verifyMutation.isPending}
onClick={() => verifyMutation.mutate(verifyCode)}
data-testid="btn-verify-2fa"
>
{verifyMutation.isPending ? 'Verifying' : 'Verify & Enable'}
</button>
<button
type="button"
className={btnGhost}
onClick={() => {
setPhase('idle');
setQr(null);
setVerifyCode('');
}}
>
Cancel
</button>
</div>
</div>
)}
{phase === 'disabling' && (
<div className="space-y-2 p-3 bg-muted/40 rounded-md max-w-sm">
<label className="block text-sm font-medium">Enter your password to confirm:</label>
<input
type="password"
className={input}
value={disablePw}
onChange={(e) => setDisablePw(e.target.value)}
placeholder="Password"
data-testid="2fa-disable-password"
/>
<div className="flex gap-2">
<button
type="button"
className={btnDanger}
disabled={!disablePw || disableMutation.isPending}
onClick={() => disableMutation.mutate(disablePw)}
data-testid="btn-disable-2fa-confirm"
>
{disableMutation.isPending ? 'Disabling' : 'Confirm Disable'}
</button>
<button
type="button"
className={btnGhost}
onClick={() => {
setPhase('idle');
setDisablePw('');
}}
data-testid="btn-disable-2fa-cancel"
>
Cancel
</button>
</div>
</div>
)}
<StatusLine msg={msg} />
<ConfirmModal
open={regenOpen}
title="Regenerate backup codes?"
body="This invalidates your existing codes. Enter your current password to confirm."
confirmText="Regenerate"
danger
requirePassword
passwordPlaceholder="Current password"
busy={regenMutation.isPending}
onConfirm={(pw) => pw && regenMutation.mutate(pw)}
onCancel={() => setRegenOpen(false)}
/>
{shownBackupCodes && (
<BackupCodesDisplay
codes={shownBackupCodes}
onClose={() => setShownBackupCodes(null)}
/>
)}
</section>
);
}
// ── Active Sessions ─────────────────────────────────────────
function SessionRowItem({
s,
isCurrent,
onRevoke,
}: {
s: SessionRow;
isCurrent: boolean;
onRevoke: () => void;
}) {
return (
<div
className={
'flex items-center justify-between gap-3 rounded-md border-2 px-3 py-2 ' +
(isCurrent ? 'border-primary bg-primary/5' : 'border-border bg-muted/40')
}
data-testid={'session-row-' + s.id}
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{s.device_label || 'Unknown device'}
{isCurrent && <span className="ml-2 text-xs text-primary font-medium">(this device)</span>}
</div>
<div className="text-xs text-muted-foreground">
{s.ip_address || ''} · Created {new Date(s.created_at).toLocaleDateString()} · Active {timeAgo(s.last_activity)}
</div>
</div>
{!isCurrent && (
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={onRevoke}
data-testid={'btn-revoke-session-' + s.id}
>
Revoke
</button>
)}
</div>
);
}
function SessionsCard() {
const qc = useQueryClient();
const [confirm, setConfirm] = useState<
| { kind: 'one'; id: string }
| { kind: 'all' }
| null
>(null);
const [msg, setMsg] = useState<Msg>(null);
const { data, isLoading, error } = useQuery<SessionsOk>({
queryKey: ['sessions'],
queryFn: () => api.get<SessionsOk>('/api/sessions'),
});
const revokeOne = useMutation({
mutationFn: (id: string) => api.delete<{ success: true }>('/api/sessions/' + id),
onSuccess: () => {
setMsg({ text: 'Session revoked', kind: 'info' });
qc.invalidateQueries({ queryKey: ['sessions'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const revokeAll = useMutation({
mutationFn: () => api.delete<RevokeAllSessionsOk>('/api/sessions'),
onSuccess: (data) => {
setMsg({ text: 'All other sessions revoked (' + (data.revoked || 0) + ' removed)', kind: 'info' });
qc.invalidateQueries({ queryKey: ['sessions'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
const busy = revokeOne.isPending || revokeAll.isPending;
return (
<section className={card} data-testid="sessions-section">
<h3 className="text-base font-semibold">Active Sessions</h3>
<p className="text-sm text-muted-foreground">
Devices where you are currently logged in. Revoke any session to immediately log that device out.
</p>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setConfirm({ kind: 'all' })}
data-testid="btn-revoke-all-sessions"
>
Revoke All Other Sessions
</button>
{isLoading && <div className="text-sm text-muted-foreground">Loading sessions…</div>}
{error && <div className="text-sm text-destructive">Could not load sessions.</div>}
<div className="space-y-2">
{data?.sessions.map((s) => (
<SessionRowItem
key={s.id}
s={s}
isCurrent={s.id === data.currentSessionId}
onRevoke={() => setConfirm({ kind: 'one', id: s.id })}
/>
))}
{data && data.sessions.length === 0 && (
<div className="text-sm text-muted-foreground">No active sessions found.</div>
)}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={confirm?.kind === 'one'}
title="Revoke this session?"
body="That device will be logged out."
confirmText="Revoke"
danger
busy={busy}
onConfirm={() => {
if (confirm?.kind === 'one') revokeOne.mutate(confirm.id);
setConfirm(null);
}}
onCancel={() => setConfirm(null)}
/>
<ConfirmModal
open={confirm?.kind === 'all'}
title="Revoke all other sessions?"
body="All other devices will be logged out."
confirmText="Revoke All"
danger
busy={busy}
onConfirm={() => {
revokeAll.mutate();
setConfirm(null);
}}
onCancel={() => setConfirm(null)}
/>
</section>
);
}
// ── 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>
);
}
// ── Voice Preferences (STT model + TTS voice) ───────────────
function VoicePreferencesCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [sttModel, setSttModel] = useState('');
const [ttsVoice, setTtsVoice] = useState('');
const [previewing, setPreviewing] = useState(false);
const [hydrated, setHydrated] = useState(false);
const { data: options } = useQuery<PreferencesOptionsOk>({
queryKey: ['voice-options'],
queryFn: () => api.get<PreferencesOptionsOk>('/api/user/preferences/options'),
});
const { data: prefs } = useQuery<UserPreferencesOk>({
queryKey: ['voice-prefs'],
queryFn: () => api.get<UserPreferencesOk>('/api/user/preferences'),
});
// Hydrate selection from the server once — subsequent typing is local.
if (!hydrated && prefs) {
setSttModel(prefs.stt_model || '');
setTtsVoice(prefs.tts_voice || '');
setHydrated(true);
}
const save = useMutation({
mutationFn: (body: { stt_model: string | null; tts_voice: string | null }) =>
api.post<{ success: true }>('/api/user/preferences', body),
onSuccess: () => {
setMsg({ text: 'Voice preferences saved', kind: 'ok' });
qc.invalidateQueries({ queryKey: ['voice-prefs'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
});
async function previewVoice() {
setMsg(null);
setPreviewing(true);
try {
// First persist the selection so the server renders with the chosen voice.
await api.post('/api/user/preferences', { tts_voice: ttsVoice || null });
qc.invalidateQueries({ queryKey: ['voice-prefs'] });
const text =
'Hello, this is a preview of the ' +
(ttsVoice || 'server default') +
' voice. This is how your read-aloud feature will sound.';
const resp = await fetch('/api/text-to-speech', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!resp.ok) throw new Error('Preview failed');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
await audio.play();
} catch (e) {
setMsg({ text: 'Preview failed: ' + (e as Error).message, kind: 'err' });
} finally {
setPreviewing(false);
}
}
return (
<section className={card} data-testid="voice-preferences-section">
<h3 className="text-base font-semibold">Voice Preferences</h3>
<p className="text-sm text-muted-foreground">
Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.
</p>
<div className="space-y-2 max-w-md">
<label className="block text-sm">
<span className="block text-xs font-semibold text-muted-foreground mb-1">
Speech-to-Text Model (Transcription)
</span>
<select
className={input}
value={sttModel}
onChange={(e) => setSttModel(e.target.value)}
data-testid="stt-model-select"
>
<option value="">Server default{options ? ' (' + options.sttProvider + ')' : ''}</option>
{options?.sttModels.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="block text-xs font-semibold text-muted-foreground mb-1">
Text-to-Speech Voice (Read Aloud)
</span>
<div className="flex gap-2">
<select
className={input}
value={ttsVoice}
onChange={(e) => setTtsVoice(e.target.value)}
data-testid="tts-voice-select"
>
<option value="">Server default{options ? ' (' + options.ttsProvider + ')' : ''}</option>
{options?.ttsVoices.map((v) => (
<option key={v.value} value={v.value}>{v.label}</option>
))}
</select>
<button
type="button"
className={btnGhost}
disabled={previewing}
onClick={previewVoice}
data-testid="btn-preview-voice"
>
{previewing ? 'Loading…' : 'Preview'}
</button>
</div>
</label>
<button
type="button"
className={btnPrimary}
disabled={save.isPending}
onClick={() =>
save.mutate({
stt_model: sttModel || null,
tts_voice: ttsVoice || null,
})
}
data-testid="btn-save-voice-prefs"
>
{save.isPending ? 'Saving…' : 'Save Voice Preferences'}
</button>
</div>
<StatusLine msg={msg} />
</section>
);
}
// ── Browser Whisper (WASM, local transcription) ─────────────
// UI-only port: persists enabled + model choice in localStorage under the
// same keys the vanilla BrowserWhisper module reads. The actual WASM
// model preload / transcription still lives in the vanilla
// public/js/browserWhisper.js module and will wire through when the
// recording components port to React.
const BW_ENABLED_KEY = 'ped_browser_whisper_enabled';
const BW_MODEL_KEY = 'ped_browser_whisper_model';
const BW_MODELS: VoiceOption[] = [
{ value: 'Xenova/whisper-tiny.en', label: 'Tiny (~39MB) — fastest, ~2-3s' },
{ value: 'Xenova/whisper-base.en', label: 'Base (~74MB) — balanced, ~3-5s' },
{ value: 'Xenova/whisper-small.en', label: 'Small (~244MB) — best quality, ~6-10s' },
];
function readLS(k: string, fallback = ''): string {
try { return window.localStorage.getItem(k) ?? fallback; } catch { return fallback; }
}
function writeLS(k: string, v: string) {
try { window.localStorage.setItem(k, v); } catch { /* ignore */ }
}
function BrowserWhisperCard() {
const [enabled, setEnabled] = useState(readLS(BW_ENABLED_KEY) === 'true');
const [model, setModel] = useState(readLS(BW_MODEL_KEY) || BW_MODELS[0].value);
function toggle(next: boolean) {
setEnabled(next);
writeLS(BW_ENABLED_KEY, String(next));
}
function changeModel(next: string) {
setModel(next);
writeLS(BW_MODEL_KEY, next);
}
return (
<section className={card} data-testid="browser-whisper-section">
<h3 className="text-base font-semibold">Browser Transcription (Local Whisper)</h3>
<p className="text-sm text-muted-foreground">
Transcribes audio entirely in your browser no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally.
</p>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Enable browser transcription:</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-primary size-4"
checked={enabled}
onChange={(e) => toggle(e.target.checked)}
data-testid="browser-whisper-enabled"
/>
<span className="text-sm" data-testid="browser-whisper-status">
{enabled ? 'On — audio stays on device' : 'Off'}
</span>
</label>
</div>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Model:</label>
<select
className={input + ' max-w-md'}
value={model}
onChange={(e) => changeModel(e.target.value)}
data-testid="browser-whisper-model"
>
{BW_MODELS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
<p className="text-xs text-muted-foreground">
When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components pre-download will light up when those port to React.
</p>
</section>
);
}
// ── Web Speech Recognition (real-time streaming) ────────────
const WS_ENABLED_KEY = 'ped_web_speech_enabled';
function WebSpeechCard() {
const [enabled, setEnabled] = useState(readLS(WS_ENABLED_KEY) === 'true');
const [confirmOpen, setConfirmOpen] = useState(false);
const supported =
typeof window !== 'undefined' &&
('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
function enable() {
// Enabling Web Speech flips Browser Whisper off (the vanilla app enforces
// the same priority: Web Speech > Browser Whisper > server).
writeLS(BW_ENABLED_KEY, 'false');
setEnabled(true);
writeLS(WS_ENABLED_KEY, 'true');
}
function disable() {
setEnabled(false);
writeLS(WS_ENABLED_KEY, 'false');
}
return (
<section className={card + ' border-l-4 border-l-orange-500'} data-testid="web-speech-section">
<h3 className="text-base font-semibold">Real-Time Streaming Transcription</h3>
<div className="bg-orange-50 dark:bg-orange-950/30 p-3 rounded-md text-sm text-orange-900 dark:text-orange-100">
<strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.
</div>
<p className="text-sm text-muted-foreground">
See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.
</p>
<div className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium">Enable real-time streaming:</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="accent-orange-500 size-4"
checked={enabled}
disabled={!supported}
onChange={(e) => {
if (e.target.checked) setConfirmOpen(true);
else disable();
}}
data-testid="web-speech-enabled"
/>
<span className="text-sm" data-testid="web-speech-status">
{!supported ? 'Not supported in this browser' : enabled ? 'On real-time streaming' : 'Off'}
</span>
</label>
</div>
{supported && (
<p className="text-xs text-muted-foreground">
Streaming integration lights up when the recording components port to React.
</p>
)}
<ConfirmModal
open={confirmOpen}
title="Enable real-time streaming transcription?"
body="Your browser's speech recognition may send audio to cloud servers (e.g. Google). This is NOT HIPAA-compliant. Only enable if you understand and accept this privacy trade-off."
confirmText="Enable"
danger
onConfirm={() => {
enable();
setConfirmOpen(false);
}}
onCancel={() => setConfirmOpen(false)}
/>
</section>
);
}
// ── My Templates (Memories, non-correction entries) ─────────
const TEMPLATE_CATEGORIES: Array<{ value: string; label: string }> = [
{ value: 'physical_exam', label: 'Physical Exam Template' },
{ value: 'ros', label: 'Review of Systems Template' },
{ value: 'encounter_format', label: 'Encounter Note Format' },
{ value: 'family_history', label: 'Family History Format' },
{ value: 'assessment_plan', label: 'Assessment & Plan Format' },
{ value: 'template_soap', label: 'SOAP Note Template' },
{ value: 'template_hpi', label: 'HPI Template' },
{ value: 'template_wellvisit', label: 'Well Visit Template' },
{ value: 'template_sickvisit', label: 'Sick Visit Template' },
{ value: 'custom', label: 'Custom' },
];
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')])
);
function TemplatesCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value);
const [name, setName] = useState('');
const [content, setContent] = useState('');
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
const { data } = useQuery<MemoriesOk>({
queryKey: ['memories'],
queryFn: () => api.get<MemoriesOk>('/api/memories'),
});
const templates = (data?.memories || []).filter((m) => !m.category.startsWith('correction_'));
const save = useMutation({
mutationFn: (body: { id?: number; category: string; name: string; content: string }) => {
const { id, ...rest } = body;
return id
? api.put<{ success: true }>('/api/memories/' + id, rest)
: api.post<{ success: true }>('/api/memories', rest);
},
onSuccess: () => {
setMsg({ text: editingId ? 'Template updated' : 'Template saved', kind: 'ok' });
setEditingId(null);
setName('');
setContent('');
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
onSuccess: () => {
setMsg({ text: 'Template deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
});
function beginEdit(m: MemoryRow) {
setEditingId(m.id);
setCategory(m.category);
setName(m.name);
setContent(m.content);
}
function cancelEdit() {
setEditingId(null);
setName('');
setContent('');
}
function submit(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!name.trim()) return setMsg({ text: 'Enter a template name', kind: 'err' });
if (!content.trim()) return setMsg({ text: 'Enter template content', kind: 'err' });
save.mutate({ id: editingId ?? undefined, category, name: name.trim(), content: content.trim() });
}
return (
<section className={card} data-testid="templates-section">
<h3 className="text-base font-semibold">My Templates</h3>
<p className="text-sm text-muted-foreground">
Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.
</p>
<form onSubmit={submit} className="space-y-2">
<div className="flex gap-2 flex-wrap items-center">
<select
className={input + ' max-w-xs'}
value={category}
onChange={(e) => setCategory(e.target.value)}
data-testid="mem-category"
>
{TEMPLATE_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<input
type="text"
className={input + ' flex-1 min-w-[150px]'}
placeholder="Template name (e.g. Normal PE)"
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="mem-name"
/>
</div>
<textarea
rows={5}
className={input + ' resize-y'}
placeholder="Paste your template here. Example: HEENT: Normocephalic, atraumatic. Eyes: PERRL"
value={content}
onChange={(e) => setContent(e.target.value)}
data-testid="mem-content"
/>
<div className="flex gap-2">
<button
type="submit"
className={btnPrimary}
disabled={save.isPending}
data-testid="btn-mem-save"
>
{save.isPending ? 'Saving…' : editingId ? 'Update Template' : 'Add Template'}
</button>
{editingId !== null && (
<button type="button" className={btnGhost} onClick={cancelEdit}>Cancel</button>
)}
</div>
</form>
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-sm text-muted-foreground">No templates saved yet. Add one above.</div>
) : (
templates.map((m) => (
<div
key={m.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'mem-row-' + m.id}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{m.name}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{CATEGORY_LABEL[m.category] || m.category}
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{(m.content || '').slice(0, 100).replace(/\n/g, ' ')}
{m.content && m.content.length > 100 ? '…' : ''}
</div>
</div>
<button
type="button"
className={btnGhost + ' text-xs'}
onClick={() => beginEdit(m)}
data-testid={'btn-mem-edit-' + m.id}
>
Edit
</button>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(m)}
data-testid={'btn-mem-delete-' + m.id}
>
Delete
</button>
</div>
))
)}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title={'Delete template "' + (deleteTarget?.name || '') + '"?'}
body="This cannot be undone."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── AI Corrections (read-only, expandable) ──────────────────
const CORRECTION_LABEL: Record<string, string> = {
correction_soap: 'SOAP Correction',
correction_hpi: 'HPI Correction',
correction_encounter: 'Encounter Correction',
correction_wellvisit: 'Well Visit Correction',
correction_sickvisit: 'Sick Visit Correction',
};
function parseCorrection(content: string): { original: string; corrected: string } {
const idx = content.indexOf('\nCORRECTED TO: ');
if (idx === -1) return { original: content.trim(), corrected: '' };
return {
original: content.substring(0, idx).replace(/^ORIGINAL:\s*/i, '').trim(),
corrected: content.substring(idx + '\nCORRECTED TO: '.length).trim(),
};
}
function CorrectionsCard() {
const qc = useQueryClient();
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<MemoryRow | null>(null);
const { data } = useQuery<MemoriesOk>({
queryKey: ['memories'],
queryFn: () => api.get<MemoriesOk>('/api/memories'),
});
const corrections = (data?.memories || []).filter((m) => m.category.startsWith('correction_'));
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id),
onSuccess: () => {
setMsg({ text: 'Correction deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['memories'] });
},
onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }),
});
return (
<section className={card} data-testid="corrections-section">
<h3 className="text-base font-semibold">AI Learning (Corrections)</h3>
<p className="text-sm text-muted-foreground">
The AI automatically learns from your edits. When you modify AI-generated text and save, corrections are stored here and applied to future notes. Latest 20 per section.
</p>
<div className="space-y-2">
{corrections.length === 0 ? (
<div className="text-sm text-muted-foreground">
No corrections yet. Edit AI-generated notes and save to start learning.
</div>
) : (
corrections.map((m) => {
const isOpen = expanded[m.id];
const parts = parseCorrection(m.content || '');
const date = m.created_at ? new Date(m.created_at).toLocaleDateString() : '';
return (
<div
key={m.id}
className="rounded-md bg-muted/40 border border-border overflow-hidden"
data-testid={'corr-row-' + m.id}
>
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2 text-left"
onClick={() => setExpanded({ ...expanded, [m.id]: !isOpen })}
>
<span className="text-xs text-muted-foreground">{isOpen ? '▾' : '▸'}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{CORRECTION_LABEL[m.category] || m.category}
</span>
<span className="flex-1 text-sm truncate">{m.name}</span>
<span className="text-xs text-muted-foreground">{date}</span>
<span
role="button"
className="text-destructive text-xs px-2"
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
data-testid={'btn-corr-delete-' + m.id}
>
Delete
</span>
</button>
{isOpen && (
<div className="px-3 py-2 text-sm space-y-2 border-t border-border">
<div>
<div className="text-xs font-semibold uppercase text-destructive mb-1">Original (AI generated):</div>
<div className="whitespace-pre-wrap text-muted-foreground">{parts.original}</div>
</div>
{parts.corrected && (
<div>
<div className="text-xs font-semibold uppercase text-green-600 mb-1">Corrected to:</div>
<div className="whitespace-pre-wrap">{parts.corrected}</div>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this correction?"
body="The AI will no longer apply this edit in future notes."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Audio Backups (server-stored, 24h TTL) ──────────────────
function AudioBackupsCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<AudioBackupRow | null>(null);
const { data, isLoading } = useQuery<AudioBackupsListOk>({
queryKey: ['audio-backups'],
queryFn: () => api.get<AudioBackupsListOk>('/api/audio-backups'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/audio-backups/' + id),
onSuccess: () => {
setMsg({ text: 'Backup deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['audio-backups'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
function playBackup(id: number) {
// Server streams the decompressed audio. Open in a new tab; browser
// picks the default audio player.
window.open('/api/audio-backups/' + id + '/audio', '_blank', 'noopener,noreferrer');
}
return (
<section className={card} data-testid="audio-backups-section">
<h3 className="text-base font-semibold">Audio Backups</h3>
<p className="text-sm text-muted-foreground">
Recordings are automatically backed up on the server and kept for 24 hours. Retry flow ports with the recording components.
</p>
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
<div className="space-y-2">
{data && data.backups.length === 0 && (
<div className="text-sm text-muted-foreground">No audio backups.</div>
)}
{data?.backups.map((b) => {
const sizeKb = Math.round(b.size_bytes / 1024);
const comp = b.compressed_bytes ? ' (' + Math.round(b.compressed_bytes / 1024) + ' KB compressed)' : '';
return (
<div
key={b.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'audio-backup-row-' + b.id}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{b.module} recording</div>
<div className="text-xs text-muted-foreground">
{new Date(b.created_at).toLocaleString()} · {sizeKb} KB{comp} · {timeAgo(b.created_at)}
</div>
</div>
<button
type="button"
className={btnPrimary + ' text-xs'}
onClick={() => playBackup(b.id)}
>
Play
</button>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(b)}
data-testid={'btn-audio-delete-' + b.id}
>
Delete
</button>
</div>
);
})}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this audio backup?"
body="This cannot be undone."
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Saved Encounters (auto-deleted after 7 days) ────────────
function SavedEncountersCard() {
const qc = useQueryClient();
const [msg, setMsg] = useState<Msg>(null);
const [deleteTarget, setDeleteTarget] = useState<SavedEncounterRow | null>(null);
const { data, isLoading } = useQuery<SavedEncountersListOk>({
queryKey: ['saved-encounters'],
queryFn: () => api.get<SavedEncountersListOk>('/api/encounters/saved'),
});
const del = useMutation({
mutationFn: (id: number) => api.delete<{ success: true }>('/api/encounters/saved/' + id),
onSuccess: () => {
setMsg({ text: 'Deleted', kind: 'info' });
qc.invalidateQueries({ queryKey: ['saved-encounters'] });
},
onError: (e: Error) => setMsg({ text: e.message, kind: 'err' }),
});
return (
<section className={card} data-testid="saved-encounters-section">
<h3 className="text-base font-semibold">Saved Encounters</h3>
<p className="text-sm text-muted-foreground">
Encounters are automatically deleted after 7 days per site policy. Resume action ports with the encounter components.
</p>
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
<div className="space-y-2">
{data && data.encounters.length === 0 && (
<div className="text-sm text-muted-foreground">No saved encounters.</div>
)}
{data?.encounters.map((enc) => {
const date = new Date(enc.updated_at).toLocaleDateString();
const expires = new Date(enc.expires_at).toLocaleDateString();
const preview = (enc.transcript_preview || '').slice(0, 80);
return (
<div
key={enc.id}
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/40 border border-border"
data-testid={'enc-row-' + enc.id}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium truncate">{enc.label || 'Untitled'}</span>
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{enc.enc_type}
</span>
</div>
<div className="text-xs text-muted-foreground truncate">
{date} · expires {expires}{preview ? ' · ' + preview + '…' : ''}
</div>
</div>
<button
type="button"
className={btnGhost + ' text-destructive text-xs'}
onClick={() => setDeleteTarget(enc)}
data-testid={'btn-enc-delete-' + enc.id}
>
Delete
</button>
</div>
);
})}
</div>
<StatusLine msg={msg} />
<ConfirmModal
open={!!deleteTarget}
title="Delete this saved encounter?"
body={deleteTarget?.label || undefined}
confirmText="Delete"
danger
busy={del.isPending}
onConfirm={() => {
if (deleteTarget) del.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
onCancel={() => setDeleteTarget(null)}
/>
</section>
);
}
// ── Compliance (static info card) ───────────────────────────
function ComplianceCard() {
return (
<section className={card} data-testid="compliance-section">
<h3 className="text-base font-semibold">Compliance & Usage</h3>
<div className="text-sm space-y-2">
<p>
<strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.
</p>
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
<li>All connections use HTTPS/TLS encryption</li>
<li>Authentication with optional 2FA</li>
<li>No patient data stored on server beyond session</li>
<li>AWS Bedrock supports BAA for HIPAA compliance</li>
<li>Azure OpenAI supports BAA for HIPAA compliance</li>
</ul>
<p>
<strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.
</p>
</div>
</section>
);
}
// ── Page shell ───────────────────────────────────────────────
export default function Settings() {
const { data: me, isLoading, error } = useQuery<MeOk>({
queryKey: ['auth-me'],
queryFn: () => api.get<MeOk>('/api/auth/me'),
});
// Default to showing local-auth UIs unless the server explicitly says
// canLocalAuth === false. Matches vanilla behavior: if the flag is
// absent (older server, transient hiccup), we still render the section.
const canLocal = me?.user.canLocalAuth !== false;
return (
<div className="max-w-3xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground">
Account security, integrations, and personal templates. More sub-sections port over in follow-up commits.
</p>
</header>
{isLoading && <div className="text-sm text-muted-foreground">Loading…</div>}
{error && (
<div className="text-sm text-destructive">Could not load your account: {(error as Error).message}</div>
)}
{me && canLocal && (
<>
<ChangePasswordCard />
<TwoFactorCard user={me.user} />
<SessionsCard />
</>
)}
{me && !canLocal && (
<section className={card}>
<h3 className="text-base font-semibold">Account managed by single sign-on</h3>
<p className="text-sm text-muted-foreground">
Your password and two-factor authentication are managed by your identity provider.
</p>
</section>
)}
{/* Integrations — shown to all users, not gated by canLocalAuth. */}
{me && <NextcloudCard user={me.user} />}
{me && <DocumentsCard />}
{/* Voice + transcription */}
{me && <VoicePreferencesCard />}
{me && <BrowserWhisperCard />}
{me && <WebSpeechCard />}
{/* Personal content */}
{me && <TemplatesCard />}
{me && <CorrectionsCard />}
{me && <AudioBackupsCard />}
{me && <SavedEncountersCard />}
<ComplianceCard />
</div>
);
}