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.
1789 lines
65 KiB
TypeScript
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>
|
|
);
|
|
}
|