pediatric-ai-scribe-v3/client/src/components/ConfirmModal.tsx
Daniel 9dce93cf67 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.
2026-04-23 23:31:14 +02:00

111 lines
3.5 KiB
TypeScript

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