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.
This commit is contained in:
Anso
2026-04-20 17:58:57 -04:00
committed by GitHub
parent d95e154aeb
commit 0a0198013d
11 changed files with 1076 additions and 623 deletions
@@ -1,12 +1,20 @@
import { useRef, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Download } from 'lucide-react';
import { Check, Copy, Download } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa';
import { OtpDigitField } from '@/components/auth/OtpDigitField';
import { ErrorRail } from '@/components/auth/ErrorRail';
interface MfaBackupCodesDialogProps {
open: boolean;
@@ -21,6 +29,7 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [confirmState, setConfirmState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const submittedRef = useRef(false);
@@ -28,12 +37,14 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
setStep('confirm');
setCode('');
setError('');
setConfirmState('idle');
setBackupCodes([]);
submittedRef.current = false;
};
const submitRegenerate = async (valueToSubmit: string) => {
setError('');
setConfirmState('loading');
setLoading(true);
try {
const res = await apiFetch('/auth/mfa/backup-codes/regenerate', {
@@ -45,30 +56,30 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
if (!res.ok) {
setError(data?.error || 'Could not regenerate backup codes');
setCode('');
setConfirmState('error');
submittedRef.current = false;
window.setTimeout(() => setConfirmState('idle'), 600);
return;
}
setBackupCodes(data.backupCodes || []);
setConfirmState('success');
setStep('show');
} catch (err) {
setError((err as Error)?.message || 'Could not regenerate backup codes');
setConfirmState('error');
submittedRef.current = false;
} finally {
setLoading(false);
}
};
const handleConfirm = (e: React.FormEvent) => {
e.preventDefault();
if (loading || code.length !== TOTP_LENGTH) return;
submittedRef.current = true;
void submitRegenerate(code);
};
const handleCodeChange = (raw: string) => {
const normalized = normalizeTotpInput(raw);
setCode(normalized);
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
if (normalized.length < TOTP_LENGTH) {
submittedRef.current = false;
if (confirmState === 'error') setConfirmState('idle');
}
if (
normalized.length === TOTP_LENGTH &&
!loading &&
@@ -120,74 +131,119 @@ export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaB
onOpenChange(next);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'confirm' ? 'Regenerate backup codes' : 'New backup codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Replace your backup codes with a freshly generated set. The previous
set stops working immediately.
</DialogDescription>
</DialogHeader>
<DialogContent className="max-w-md overflow-hidden p-0">
<div className="relative">
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-brand/70" />
{step === 'confirm' && (
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Your current backup codes will stop working immediately. Confirm with a code from your authenticator app to continue.
</p>
<div className="grid gap-2">
<Label htmlFor="mfa-regen-code">Verification code</Label>
<Input
id="mfa-regen-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
maxLength={TOTP_LENGTH}
value={code}
onChange={(e) => handleCodeChange(e.target.value)}
className="font-mono tabular-nums tracking-widest text-center"
placeholder="123456"
/>
<DialogHeader 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-stat-subtitle">
SENCHO · MFA
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<DialogFooter className="gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
{loading ? 'Working...' : 'Regenerate'}
</Button>
</DialogFooter>
</form>
)}
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
{step === 'confirm' ? 'Confirm identity' : 'New recovery codes'}
</DialogTitle>
<DialogDescription className="sr-only">
Replace your backup codes with a freshly generated set. The previous
set stops working immediately.
</DialogDescription>
</DialogHeader>
{step === 'show' && (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Each code can be used once. Store them somewhere safe; they will not be shown again.
</p>
<div className="grid grid-cols-2 gap-2 rounded-md border border-card-border bg-card p-4 font-mono text-sm tabular-nums tracking-wider shadow-card-bevel">
{backupCodes.map((c) => (
<div key={c} className="text-center">{c}</div>
))}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter>
<Button type="button" onClick={handleFinish}>I&apos;ve saved these</Button>
</DialogFooter>
<div className="px-6 py-5">
{step === 'confirm' && (
<div className="flex flex-col gap-4">
<p className="text-sm leading-snug text-stat-subtitle">
Enter a code from your authenticator to generate a new set. The previous codes stop working immediately.
</p>
<OtpDigitField
id="mfa-regen-code"
value={code}
onChange={handleCodeChange}
state={confirmState}
disabled={loading || confirmState === 'success'}
autoFocus
/>
{error && <ErrorRail>{error}</ErrorRail>}
<DialogFooter className="mt-2 gap-2 sm:gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
</DialogFooter>
</div>
)}
{step === 'show' && (
<div className="flex flex-col gap-4">
<WarningRail>Previous codes have been invalidated.</WarningRail>
<p className="text-sm leading-snug text-stat-subtitle">
Each code can be used once. Store them safely. They will not be shown again.
</p>
<BackupCodeTicket codes={backupCodes} />
<div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={handleCopy}>
<Copy className="h-4 w-4" strokeWidth={1.5} />
Copy all
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={handleDownload}>
<Download className="h-4 w-4" strokeWidth={1.5} />
Download
</Button>
</div>
<DialogFooter className="mt-2">
<Button
type="button"
onClick={handleFinish}
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
<Check strokeWidth={1.5} />
Done
</Button>
</DialogFooter>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
function BackupCodeTicket({ codes }: { codes: string[] }) {
return (
<div className="overflow-hidden rounded-md border border-card-border bg-background/60 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.35)]">
<div className="flex items-center justify-between border-b border-card-border/60 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
<span>Recovery codes</span>
<span className="tabular-nums">{codes.length} issued</span>
</div>
<ol className="grid grid-cols-1 sm:grid-cols-2">
{codes.map((c, i) => (
<li
key={c}
className={cn(
'flex items-center gap-3 px-3 py-2 font-mono text-sm tabular-nums tracking-[0.15em] text-stat-value',
'border-t border-card-border/40',
i === 0 && 'sm:border-t-0',
i === 1 && 'sm:border-t-0',
)}
>
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
{String(i + 1).padStart(2, '0')}
</span>
<span>{c}</span>
</li>
))}
</ol>
</div>
);
}
function WarningRail({ children }: { children: React.ReactNode }) {
return (
<div className="relative overflow-hidden rounded-md border border-warning/30 bg-warning/8 pl-4 pr-3 py-2">
<span className="absolute inset-y-0 left-0 w-[3px] bg-warning/70" aria-hidden />
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
{children}
</div>
</div>
);
}