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.
70 lines
3.6 KiB
JavaScript
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);
|
|
});
|
|
});
|