Files
sencho/frontend/src/components/mfa/MfaDisableDialog.tsx
T
Anso 0a0198013d feat(auth): redesign login, MFA, and setup surfaces with cockpit voice (#714)
* feat(auth): redesign login, MFA, and setup surfaces with cockpit voice

Applies the cockpit design system to every auth surface: Login, MFA
challenge, first-boot Setup, and the three MFA dialogs (Enroll, Backup
Codes, Disable). Introduces shared primitives under components/auth/:
AuthCanvas shell with bevelled card and cyan left rail, AuthStepHeader
for tracked-mono kicker plus italic hero pair, OtpDigitField with six
recessed digit cells and auto-submit, and ErrorRail for consistent
inline errors.

Preserves all behaviour: Local/LDAP toggle, dynamic SSO providers,
auto-submit TOTP, backup-code fallback with dash formatting, rate-limit
countdown, three-step enrollment, cold-start setup. Surface-only change;
AuthContext, routing, and endpoints untouched.

* test(e2e): update MFA spec selectors to match redesigned auth surfaces

The auth redesign renamed buttons, restyled the backup-mode toggle to
bracketed mono, changed input ids on the challenge + disable dialogs,
and made the challenge TOTP path auto-submit (no explicit Verify
button). Updates the spec accordingly:

- Enroll step 1 button: Next -> Continue
- Enroll step 3 button: "saved these" -> Done
- Challenge heading: "Two-factor authentication" -> "Verify"
- Backup toggle: "Use a backup code instead" -> "Use backup code"
- Challenge verify button: "Verify and sign in" -> "Verify"
- Challenge input id: #mfa-code -> #mfa-otp (TOTP) / #mfa-backup (backup)
- Disable dialog backup input id: #mfa-disable-code -> #mfa-disable-backup

All six MFA tests pass locally. No production code changed.
2026-04-20 17:58:57 -04:00

203 lines
6.9 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import {
BACKUP_CODE_DISPLAY_LENGTH,
BACKUP_CODE_RAW_LENGTH,
TOTP_LENGTH,
normalizeBackupCodeInput,
normalizeTotpInput,
} from '@/lib/mfa';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface MfaDisableDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onDisabled: () => void;
}
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
const [display, setDisplay] = useState('');
const [raw, setRaw] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [otpState, setOtpState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
const submittedRef = useRef(false);
useEffect(() => {
if (open) {
setDisplay('');
setRaw('');
setError('');
setUseBackup(false);
setOtpState('idle');
submittedRef.current = false;
}
}, [open]);
const submitDisable = async (valueToSubmit: string) => {
setError('');
setOtpState('loading');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/disable', {
method: 'POST',
localOnly: true,
body: JSON.stringify({ code: valueToSubmit, isBackupCode: useBackup }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data?.error || 'Could not disable two-factor authentication');
setDisplay('');
setRaw('');
setOtpState('error');
submittedRef.current = false;
window.setTimeout(() => setOtpState('idle'), 600);
return;
}
toast.success('Two-factor authentication disabled');
setOtpState('success');
setDisplay('');
setRaw('');
onOpenChange(false);
onDisabled();
} catch (err) {
setError((err as Error)?.message || 'Could not disable two-factor authentication');
setOtpState('error');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const expectedLength = useBackup ? BACKUP_CODE_RAW_LENGTH : TOTP_LENGTH;
const handleOtpChange = (value: string) => {
const normalized = normalizeTotpInput(value);
setDisplay(normalized);
setRaw(normalized);
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (otpState === 'error') setOtpState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!loading &&
!submittedRef.current
) {
submittedRef.current = true;
requestAnimationFrame(() => { void submitDisable(normalized); });
}
};
const handleBackupChange = (value: string) => {
const next = normalizeBackupCodeInput(value);
setDisplay(next.display);
setRaw(next.raw);
if (next.raw.length < BACKUP_CODE_RAW_LENGTH) submittedRef.current = false;
if (otpState === 'error') setOtpState('idle');
};
const handleToggleBackup = () => {
setUseBackup((v) => !v);
setDisplay('');
setRaw('');
setError('');
setOtpState('idle');
submittedRef.current = false;
};
const handleDisableClick = () => {
if (loading || raw.length !== expectedLength) return;
submittedRef.current = true;
void submitDisable(raw);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-md overflow-hidden p-0">
<div className="relative">
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" />
<AlertDialogHeader className="border-b border-card-border/60 px-6 pt-6 pb-4 text-left">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-destructive">
SENCHO · MFA · DISABLE
</div>
<AlertDialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
Turn off two-factor
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 text-sm leading-snug text-stat-subtitle">
Disabling 2FA removes this login layer. Your backup codes become invalid. Confirm with a current code to proceed.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4 px-6 py-5">
{useBackup ? (
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Backup code · 10 chars
</span>
<Input
id="mfa-disable-backup"
type="text"
inputMode="text"
autoComplete="one-time-code"
maxLength={BACKUP_CODE_DISPLAY_LENGTH}
value={display}
onChange={(e) => handleBackupChange(e.target.value)}
placeholder="ABCDE-FGHIJ"
className="h-12 bg-background/60 border-card-border text-center font-mono text-lg tabular-nums tracking-[0.3em] shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)] focus-visible:border-brand/60 focus-visible:ring-2 focus-visible:ring-brand/40"
/>
</div>
) : (
<OtpDigitField
id="mfa-disable-code"
value={display}
onChange={handleOtpChange}
state={otpState}
disabled={loading || otpState === 'success'}
autoFocus
/>
)}
<button
type="button"
onClick={handleToggleBackup}
className="self-start font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle transition-colors hover:text-brand"
>
{useBackup ? '[ Use authenticator ]' : '[ Use backup code ]'}
</button>
{error && <ErrorRail>{error}</ErrorRail>}
</div>
<AlertDialogFooter className="border-t border-card-border/60 px-6 py-4">
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<Button
type="button"
variant="destructive"
disabled={loading || raw.length !== expectedLength}
onClick={handleDisableClick}
>
{loading ? 'Disabling...' : 'Disable'}
</Button>
</AlertDialogFooter>
</div>
</AlertDialogContent>
</AlertDialog>
);
}