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.
111 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|