feat(client): port Settings — Security (password, 2FA, sessions)

First of three commits porting the vanilla settings.html (13 sub-sections
total) to the React tree. This commit delivers the Settings page shell
plus the three Security sub-sections. Integrations (Nextcloud, Documents)
and Voice + Content land in the two follow-ups.

client/src/pages/Settings.tsx
  Page shell that fetches /api/auth/me and conditionally renders the
  local-auth sections only when user.canLocalAuth !== false. SSO-only
  users see a brief "managed by your identity provider" notice instead —
  matches vanilla behavior, which hides those cards for SSO accounts.

  Change Password: three-field form (current / new / confirm) with
  client-side validation (8+ chars, match). POSTs /api/auth/change-password.
  On success the server destroys all OTHER sessions, so the component
  invalidates the ['sessions'] query so the Active Sessions card below
  refreshes without a full reload. passwordWarning (pwned-password hint)
  surfaces as a follow-up info toast.

  Two-Factor Auth: status line ("Enabled" / "Not enabled") reads
  user.totp_enabled. Enable button POSTs /api/auth/setup-2fa, renders the
  returned QR + secret, accepts the 6-digit code and POSTs /verify-2fa.
  First-enable shows a one-shot BackupCodesDisplay modal with Copy + close.
  Disable flow is inline (password field + Confirm Disable + Cancel, no
  modal) — matches the vanilla UX. Backup-codes remaining count pulls
  from /api/auth/2fa/backup-codes/count; a Regenerate button opens a
  ConfirmModal with requirePassword=true and POSTs /2fa/backup-codes.

  Active Sessions: useQuery on /api/sessions renders one row per session
  with the current one highlighted. Per-row Revoke opens a ConfirmModal;
  Revoke All Other Sessions opens another ConfirmModal. Both DELETE calls
  invalidate ['sessions'] on success.

client/src/components/ConfirmModal.tsx
  Reusable styled confirmation dialog — replaces vanilla showConfirm().
  Supports a danger variant (destructive button styling) and an optional
  password-input variant for confirm-by-password flows. Escape closes,
  backdrop click closes, Enter in the password field submits. Carries
  data-testid hooks (confirm-modal-ok, confirm-modal-cancel) so Playwright
  can drive it without ever hitting window.confirm().

shared/types.ts + client/src/shared/types.ts
  Additive changes only:
    - AuthUser gains optional canLocalAuth, totp_enabled, email_verified,
      nextcloud_url/user/folder, created_at — all fields the server
      already returns from /api/auth/me but the type had never described.
    - SessionRow reshape to match the wire format the server actually
      returns (snake_case ip_address / device_label / created_at /
      last_activity), replacing the speculative camelCase draft. No
      existing consumer of SessionRow existed outside Settings, so the
      rename is a no-op for current code.
    - New types for 2FA + change-password response shapes (Setup2faOk,
      Verify2faOk, BackupCodesCountOk, RegenBackupCodesOk,
      ChangePasswordOk, RevokeAllSessionsOk).

client/src/App.tsx
  Adds <Route path="/settings" element={<Settings />} />.

client/src/components/Layout.tsx
  Flips the Settings nav entry to available: true.

e2e/tests/settings-react-security.spec.js
  Five smoke tests against /app/settings mirroring the coverage of
  settings-faq-dictation.spec.js for the vanilla tree: field/button
  presence for all three sections, password-mismatch inline error,
  revoke-all click surfaces the styled modal (with an explicit
  page.on('dialog') guard to catch any future regression to native
  confirm()).

Note: the e2e container image currently predates the /app/* route
(its /app/public/app/ directory is absent), so running this spec requires
a rebuild — deferred to Daniel's call. Client tsc -b, server tsc --noEmit,
and vite build all pass locally.
This commit is contained in:
Daniel 2026-04-23 23:30:22 +02:00
parent 7c4ccd8ae6
commit 9dce93cf67
12 changed files with 916 additions and 62 deletions

View file

@ -12,6 +12,7 @@ import ChartReview from '@/pages/ChartReview';
import WellVisit from '@/pages/WellVisit';
import VaxSchedule from '@/pages/VaxSchedule';
import Catchup from '@/pages/Catchup';
import Settings from '@/pages/Settings';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -55,6 +56,7 @@ export default function App() {
<Route path="/vaxschedule" element={<VaxSchedule />} />
<Route path="/catchup" element={<Catchup />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -0,0 +1,111 @@
// ============================================================
// CONFIRM MODAL — React replacement for the vanilla showConfirm()
// helper. Daniel's feedback is explicit: never call window.confirm()
// in the frontend — use a styled modal that matches the app's design
// language. Supports a danger variant (destructive actions like
// revoke) and an optional password-input variant (e.g. "confirm by
// entering your password" for 2FA backup-code regen).
// ============================================================
import { useEffect, useState } from 'react';
interface ConfirmModalProps {
open: boolean;
title: string;
body?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
// When true, a password field is shown and the value is passed to onConfirm.
requirePassword?: boolean;
passwordPlaceholder?: string;
onConfirm: (password?: string) => void;
onCancel: () => void;
busy?: boolean;
}
export default function ConfirmModal({
open,
title,
body,
confirmText = 'Confirm',
cancelText = 'Cancel',
danger = false,
requirePassword = false,
passwordPlaceholder = 'Password',
onConfirm,
onCancel,
busy = false,
}: ConfirmModalProps) {
const [password, setPassword] = useState('');
useEffect(() => {
if (!open) setPassword('');
}, [open]);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!open) return;
if (e.key === 'Escape') onCancel();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onCancel]);
if (!open) return null;
const confirmDisabled = busy || (requirePassword && !password);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-lg border border-border bg-background p-5 shadow-lg space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 id="confirm-modal-title" className="text-base font-semibold">{title}</h3>
{body && <p className="text-sm text-muted-foreground">{body}</p>}
{requirePassword && (
<input
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={passwordPlaceholder}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter' && !confirmDisabled) onConfirm(password);
}}
/>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-border px-3 py-2 text-sm"
data-testid="confirm-modal-cancel"
>
{cancelText}
</button>
<button
type="button"
disabled={confirmDisabled}
onClick={() => onConfirm(requirePassword ? password : undefined)}
className={
'rounded-md px-3 py-2 text-sm font-medium text-white disabled:opacity-50 ' +
(danger ? 'bg-destructive' : 'bg-primary')
}
data-testid="confirm-modal-ok"
>
{busy ? 'Working…' : confirmText}
</button>
</div>
</div>
</div>
);
}

View file

@ -51,7 +51,7 @@ const NAV: NavGroup[] = [
{
label: 'Account',
items: [
{ to: '/settings', label: 'Settings', available: false },
{ to: '/settings', label: 'Settings', available: true },
{ to: '/faq', label: 'FAQ', available: true },
],
},

View file

@ -0,0 +1,585 @@
// ============================================================
// 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,
} 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>
);
}
// ── 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>
)}
</div>
);
}

View file

@ -140,6 +140,17 @@ export interface AuthUser {
role?: string;
isVerified?: boolean;
has2FA?: boolean;
// canLocalAuth: false for SSO-auto-created accounts whose password is a
// random hex blob that can never verify. Settings hides password/2FA UI
// for those users. totp_enabled / email_verified / nextcloud_* mirror
// DB columns returned by /api/auth/me.
canLocalAuth?: boolean;
totp_enabled?: boolean;
email_verified?: boolean;
nextcloud_url?: string | null;
nextcloud_user?: string | null;
nextcloud_folder?: string | null;
created_at?: string;
}
// /api/auth/login (two-phase: may also return requires2FA / needsVerification)
@ -165,17 +176,48 @@ export interface MeOk {
}
// ── Sessions ─────────────────────────────────────────────────
// Keys match the wire shape returned by /api/sessions (snake_case from
// the DB columns, deliberately unchanged to keep the existing vanilla
// client working during migration).
export interface SessionRow {
id: string;
userAgent?: string;
ipAddress?: string;
createdAt: string;
lastUsedAt?: string;
ip_address?: string | null;
device_label?: string | null;
created_at: string;
last_activity: string;
}
export interface SessionsOk {
sessions: SessionRow[];
currentSessionId: string | null;
}
export interface RevokeAllSessionsOk {
revoked?: number;
}
// ── 2FA + password change ────────────────────────────────────
// /api/auth/setup-2fa
export interface Setup2faOk {
secret: string;
qrCode: string;
}
// /api/auth/verify-2fa — backupCodes populated only on first enable
export interface Verify2faOk {
backupCodes: string[] | null;
}
// /api/auth/2fa/backup-codes/count
export interface BackupCodesCountOk {
remaining: number;
}
// /api/auth/2fa/backup-codes (regenerate)
export interface RegenBackupCodesOk {
codes: string[];
message?: string;
}
// /api/auth/change-password
export interface ChangePasswordOk {
message: string;
passwordWarning?: string;
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {

View file

@ -0,0 +1,70 @@
// ============================================================
// SETTINGS (React port) — Security sub-sections smoke tests.
//
// Mirrors the coverage of settings-faq-dictation.spec.js for the
// Change Password / 2FA / Active Sessions sections, but against the
// React tree at /app/settings (ported in commit 1 of the Settings
// migration). The vanilla tree at / continues to be covered by
// settings-faq-dictation.spec.js.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function openReactSettings(page) {
await page.goto(E2E_BASE + '/app/settings');
// Page loads /api/auth/me first — wait for the form to hydrate.
await page.waitForSelector('[data-testid="change-password-section"], [data-testid="2fa-section"], [data-testid="sessions-section"]', {
timeout: 15000,
});
}
test.describe('React Settings — Security sections render for local-auth user', () => {
test('change-password form has all three fields + submit button', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="pw-current"]')).toBeVisible();
await expect(page.locator('[data-testid="pw-new"]')).toBeVisible();
await expect(page.locator('[data-testid="pw-confirm"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-change-password"]')).toBeVisible();
});
test('2FA section shows status + at least one setup/disable button', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="2fa-status"]')).toBeVisible();
const setupCount = await page.locator('[data-testid="btn-setup-2fa"]').count();
const disableCount = await page.locator('[data-testid="btn-disable-2fa"]').count();
expect(setupCount + disableCount).toBeGreaterThan(0);
});
test('active sessions section lists the current session + revoke-all button', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await expect(page.locator('[data-testid="sessions-section"]')).toBeVisible();
await expect(page.locator('[data-testid="btn-revoke-all-sessions"]')).toBeVisible();
// At least one session row should render (the current one).
const rowCount = await page.locator('[data-testid^="session-row-"]').count();
expect(rowCount).toBeGreaterThan(0);
});
test('change-password validation — mismatched confirm shows inline error', async ({ authedPage: _, page }) => {
await openReactSettings(page);
await page.fill('[data-testid="pw-current"]', 'whatever');
await page.fill('[data-testid="pw-new"]', 'newpassword123');
await page.fill('[data-testid="pw-confirm"]', 'different456');
await page.click('[data-testid="btn-change-password"]');
await expect(page.getByText('Passwords do not match')).toBeVisible();
});
test('revoke-all sessions triggers a styled confirm modal (not a native dialog)', async ({ authedPage: _, page }) => {
// If the code ever regresses to window.confirm(), Playwright's dialog event
// would fire and this test would hang / fail with a dialog warning.
let nativeDialogFired = false;
page.on('dialog', async (d) => { nativeDialogFired = true; await d.dismiss(); });
await openReactSettings(page);
await page.click('[data-testid="btn-revoke-all-sessions"]');
// Styled modal must be visible with the cancel button from ConfirmModal.
await expect(page.locator('[data-testid="confirm-modal-cancel"]')).toBeVisible();
await page.click('[data-testid="confirm-modal-cancel"]');
expect(nativeDialogFired).toBe(false);
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DphalJ8r.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-l9SDIDLh.css">
<script type="module" crossorigin src="/app/assets/index--QprPbuv.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-D_MsmWEo.css">
</head>
<body>
<div id="root"></div>

View file

@ -140,6 +140,17 @@ export interface AuthUser {
role?: string;
isVerified?: boolean;
has2FA?: boolean;
// canLocalAuth: false for SSO-auto-created accounts whose password is a
// random hex blob that can never verify. Settings hides password/2FA UI
// for those users. totp_enabled / email_verified / nextcloud_* mirror
// DB columns returned by /api/auth/me.
canLocalAuth?: boolean;
totp_enabled?: boolean;
email_verified?: boolean;
nextcloud_url?: string | null;
nextcloud_user?: string | null;
nextcloud_folder?: string | null;
created_at?: string;
}
// /api/auth/login (two-phase: may also return requires2FA / needsVerification)
@ -165,17 +176,48 @@ export interface MeOk {
}
// ── Sessions ─────────────────────────────────────────────────
// Keys match the wire shape returned by /api/sessions (snake_case from
// the DB columns, deliberately unchanged to keep the existing vanilla
// client working during migration).
export interface SessionRow {
id: string;
userAgent?: string;
ipAddress?: string;
createdAt: string;
lastUsedAt?: string;
ip_address?: string | null;
device_label?: string | null;
created_at: string;
last_activity: string;
}
export interface SessionsOk {
sessions: SessionRow[];
currentSessionId: string | null;
}
export interface RevokeAllSessionsOk {
revoked?: number;
}
// ── 2FA + password change ────────────────────────────────────
// /api/auth/setup-2fa
export interface Setup2faOk {
secret: string;
qrCode: string;
}
// /api/auth/verify-2fa — backupCodes populated only on first enable
export interface Verify2faOk {
backupCodes: string[] | null;
}
// /api/auth/2fa/backup-codes/count
export interface BackupCodesCountOk {
remaining: number;
}
// /api/auth/2fa/backup-codes (regenerate)
export interface RegenBackupCodesOk {
codes: string[];
message?: string;
}
// /api/auth/change-password
export interface ChangePasswordOk {
message: string;
passwordWarning?: string;
}
// ── Extensions (pagers/directory) ────────────────────────────
export interface Extension {