pediatric-ai-scribe-v3/e2e/tests/settings-react-security.spec.js
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

70 lines
3.6 KiB
JavaScript

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