// ============================================================ // 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
{msg.text}
; } 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(null); const mutation = useMutation({ mutationFn: (body: { currentPassword: string; newPassword: string }) => api.post('/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 (

Change Password

setCurrent(e.target.value)} data-testid="pw-current" /> setNewPw(e.target.value)} data-testid="pw-new" /> setConfirmPw(e.target.value)} data-testid="pw-confirm" />
); } // ── Backup codes modal — shown once after first 2FA enable ── function BackupCodesDisplay({ codes, onClose }: { codes: string[]; onClose: () => void }) { return (

Save your backup codes

Each code can be used once if you lose access to your authenticator. They will not be shown again.

          {codes.join('\n')}
        
); } // ── 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(null); const [verifyCode, setVerifyCode] = useState(''); const [disablePw, setDisablePw] = useState(''); const [shownBackupCodes, setShownBackupCodes] = useState(null); const [regenOpen, setRegenOpen] = useState(false); const [msg, setMsg] = useState(null); const { data: backupCount } = useQuery({ queryKey: ['2fa-backup-count'], queryFn: () => api.get('/api/auth/2fa/backup-codes/count'), enabled, }); const setupMutation = useMutation({ mutationFn: () => api.post('/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('/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('/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 (

Two-Factor Authentication

Status:{' '} {enabled ? '✅ Enabled' : '❌ Not enabled'}

{!enabled && phase === 'idle' && ( )} {enabled && phase === 'idle' && (
{backupCount && ( {backupCount.remaining} backup codes remaining. )} {backupCount && ( )}
)} {phase === 'setup' && qr && (

Scan this QR code with your authenticator app:

2FA QR code

Or enter manually: {qr.secret}

setVerifyCode(e.target.value.replace(/\D/g, ''))} data-testid="2fa-verify-code" />
)} {phase === 'disabling' && (
setDisablePw(e.target.value)} placeholder="Password" data-testid="2fa-disable-password" />
)} pw && regenMutation.mutate(pw)} onCancel={() => setRegenOpen(false)} /> {shownBackupCodes && ( setShownBackupCodes(null)} /> )}
); } // ── Active Sessions ───────────────────────────────────────── function SessionRowItem({ s, isCurrent, onRevoke, }: { s: SessionRow; isCurrent: boolean; onRevoke: () => void; }) { return (
{s.device_label || 'Unknown device'} {isCurrent && (this device)}
{s.ip_address || '—'} · Created {new Date(s.created_at).toLocaleDateString()} · Active {timeAgo(s.last_activity)}
{!isCurrent && ( )}
); } function SessionsCard() { const qc = useQueryClient(); const [confirm, setConfirm] = useState< | { kind: 'one'; id: string } | { kind: 'all' } | null >(null); const [msg, setMsg] = useState(null); const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: () => api.get('/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('/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 (

Active Sessions

Devices where you are currently logged in. Revoke any session to immediately log that device out.

{isLoading &&
Loading sessions…
} {error &&
Could not load sessions.
}
{data?.sessions.map((s) => ( setConfirm({ kind: 'one', id: s.id })} /> ))} {data && data.sessions.length === 0 && (
No active sessions found.
)}
{ if (confirm?.kind === 'one') revokeOne.mutate(confirm.id); setConfirm(null); }} onCancel={() => setConfirm(null)} /> { revokeAll.mutate(); setConfirm(null); }} onCancel={() => setConfirm(null)} />
); } // ── 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(null); const connect = useMutation({ mutationFn: (body: { nextcloudUrl: string; username: string; appPassword: string }) => api.post('/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 (

Nextcloud Integration

Export generated documents to your Nextcloud.

{connected ? ( <>✅ Connected to {user.nextcloud_url} as {user.nextcloud_user} ) : ( <>Not connected )}
{connected && ( )}
{connected && (
)} { disconnect.mutate(); setDisconnectOpen(false); }} onCancel={() => setDisconnectOpen(false)} />
); } // ── 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 (
{doc.filename}
{formatSize(doc.size_bytes)} · {new Date(doc.created_at).toLocaleDateString()} {doc.description ? ' · ' + doc.description : ''}
); } function DocumentsCard() { const qc = useQueryClient(); const [file, setFile] = useState(null); const [description, setDescription] = useState(''); const [msg, setMsg] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const { data, isLoading, error } = useQuery({ queryKey: ['documents'], queryFn: () => api.get('/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('/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 (

Documents

Upload and manage documents via S3 storage (PDF, images, Word docs, text files). Max 10 MB per file.

{isLoading &&
Loading…
} {error &&
Failed to load documents.
} {data && !data.s3_configured && (
S3 storage not configured. Set S3_BUCKET in server environment.
)} {data && data.s3_configured && ( <>
setFile(e.target.files?.[0] ?? null)} data-testid="doc-file-input" /> setDescription(e.target.value)} data-testid="doc-description" />
{data.documents.length === 0 ? (
No documents uploaded yet.
) : ( data.documents.map((d) => ( download.mutate(d.id)} onDelete={() => setDeleteTarget(d)} downloading={download.isPending && download.variables === d.id} /> )) )}
)} { if (deleteTarget) del.mutate(deleteTarget.id); setDeleteTarget(null); }} onCancel={() => setDeleteTarget(null)} />
); } // ── Voice Preferences (STT model + TTS voice) ─────────────── function VoicePreferencesCard() { const qc = useQueryClient(); const [msg, setMsg] = useState(null); const [sttModel, setSttModel] = useState(''); const [ttsVoice, setTtsVoice] = useState(''); const [previewing, setPreviewing] = useState(false); const [hydrated, setHydrated] = useState(false); const { data: options } = useQuery({ queryKey: ['voice-options'], queryFn: () => api.get('/api/user/preferences/options'), }); const { data: prefs } = useQuery({ queryKey: ['voice-prefs'], queryFn: () => api.get('/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 (

Voice Preferences

Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features.

); } // ── 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 (

Browser Transcription (Local Whisper)

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.

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.

); } // ── 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 (

Real-Time Streaming Transcription

Privacy Warning: Uses your browser's built-in speech recognition, which may send audio to cloud servers (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.

See words appear as you speak (streaming). Overrides browser and server transcription when enabled. Not HIPAA-compliant in most browsers.

{supported && (

Streaming integration lights up when the recording components port to React.

)} { enable(); setConfirmOpen(false); }} onCancel={() => setConfirmOpen(false)} />
); } // ── 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 = Object.fromEntries( TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')]) ); function TemplatesCard() { const qc = useQueryClient(); const [msg, setMsg] = useState(null); const [editingId, setEditingId] = useState(null); const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value); const [name, setName] = useState(''); const [content, setContent] = useState(''); const [deleteTarget, setDeleteTarget] = useState(null); const { data } = useQuery({ queryKey: ['memories'], queryFn: () => api.get('/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 (

My Templates

Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes.

setName(e.target.value)} data-testid="mem-name" />