mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import {
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
normalizeBackupCodeInput,
|
||||
normalizeTotpInput,
|
||||
} from '@/lib/mfa';
|
||||
import { OtpDigitField } from '@/components/auth/OtpDigitField';
|
||||
import { ErrorRail } from '@/components/auth/ErrorRail';
|
||||
|
||||
interface MfaDisableDialogProps {
|
||||
open: boolean;
|
||||
@@ -28,13 +29,12 @@ interface MfaDisableDialogProps {
|
||||
}
|
||||
|
||||
export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableDialogProps) {
|
||||
// `display` is what the input shows (backup codes carry a dash after five chars);
|
||||
// `raw` is the normalized value sent to the server.
|
||||
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(() => {
|
||||
@@ -43,12 +43,14 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
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', {
|
||||
@@ -61,16 +63,20 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
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);
|
||||
@@ -79,19 +85,14 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
|
||||
const expectedLength = useBackup ? BACKUP_CODE_RAW_LENGTH : TOTP_LENGTH;
|
||||
|
||||
const handleCodeChange = (value: string) => {
|
||||
if (useBackup) {
|
||||
const next = normalizeBackupCodeInput(value);
|
||||
setDisplay(next.display);
|
||||
setRaw(next.raw);
|
||||
// Never auto-submit a backup code; the action is destructive.
|
||||
if (next.raw.length < BACKUP_CODE_RAW_LENGTH) submittedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const handleOtpChange = (value: string) => {
|
||||
const normalized = normalizeTotpInput(value);
|
||||
setDisplay(normalized);
|
||||
setRaw(normalized);
|
||||
if (normalized.length < TOTP_LENGTH) submittedRef.current = false;
|
||||
if (normalized.length < TOTP_LENGTH) {
|
||||
submittedRef.current = false;
|
||||
if (otpState === 'error') setOtpState('idle');
|
||||
}
|
||||
if (
|
||||
normalized.length === TOTP_LENGTH &&
|
||||
!loading &&
|
||||
@@ -102,11 +103,20 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -118,51 +128,74 @@ export function MfaDisableDialog({ open, onOpenChange, onDisabled }: MfaDisableD
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disable two-factor authentication?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your account will only be protected by a password. Anyone who obtains that password can sign in. Confirm with a current code to continue.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<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" />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mfa-disable-code">{useBackup ? 'Backup code' : 'Verification code'}</Label>
|
||||
<Input
|
||||
id="mfa-disable-code"
|
||||
type="text"
|
||||
inputMode={useBackup ? 'text' : 'numeric'}
|
||||
autoComplete="one-time-code"
|
||||
maxLength={useBackup ? BACKUP_CODE_DISPLAY_LENGTH : TOTP_LENGTH}
|
||||
value={display}
|
||||
onChange={(e) => handleCodeChange(e.target.value)}
|
||||
className="font-mono tabular-nums tracking-widest text-center"
|
||||
placeholder={useBackup ? 'ABCDE-FGHIJ' : '123456'}
|
||||
/>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
|
||||
onClick={handleToggleBackup}
|
||||
>
|
||||
{useBackup ? 'Use your authenticator app instead' : 'Use a backup code instead'}
|
||||
</button>
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||
disabled={loading || raw.length !== expectedLength}
|
||||
onClick={handleDisableClick}
|
||||
>
|
||||
{loading ? 'Disabling...' : 'Disable'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.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, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ArrowRight, Check, Copy, Download, Loader2 } 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 MfaEnrollDialogProps {
|
||||
open: boolean;
|
||||
@@ -17,10 +25,6 @@ interface MfaEnrollDialogProps {
|
||||
|
||||
type Step = 'qr' | 'confirm' | 'backup';
|
||||
|
||||
/**
|
||||
* Format a raw base32 secret as groups of 4 characters so it is easier for
|
||||
* users typing it into authenticator apps manually.
|
||||
*/
|
||||
function formatSecret(secret: string): string {
|
||||
return secret.replace(/(.{4})/g, '$1 ').trim();
|
||||
}
|
||||
@@ -30,14 +34,12 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [otpauthUri, setOtpauthUri] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [confirmState, setConfirmState] = useState<'idle' | 'loading' | 'error' | 'success'>('idle');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
// Latch so auto-submit only fires once per complete entry.
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
// When the dialog opens, start enrolment so the QR is ready immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
@@ -45,7 +47,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
setCode('');
|
||||
setError('');
|
||||
setBackupCodes([]);
|
||||
setShowSecret(false);
|
||||
setConfirmState('idle');
|
||||
submittedRef.current = false;
|
||||
setLoading(true);
|
||||
apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true })
|
||||
@@ -71,6 +73,7 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
|
||||
const submitConfirm = async (valueToSubmit: string) => {
|
||||
setError('');
|
||||
setConfirmState('loading');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/auth/mfa/enroll/confirm', {
|
||||
@@ -82,30 +85,30 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
if (!res.ok) {
|
||||
setError(data?.error || 'Verification failed');
|
||||
setCode('');
|
||||
setConfirmState('error');
|
||||
submittedRef.current = false;
|
||||
window.setTimeout(() => setConfirmState('idle'), 600);
|
||||
return;
|
||||
}
|
||||
setBackupCodes(data.backupCodes || []);
|
||||
setConfirmState('success');
|
||||
setStep('backup');
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message || 'Verification failed');
|
||||
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 submitConfirm(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 &&
|
||||
@@ -158,120 +161,197 @@ export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDia
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
// Once backup codes have been shown, a close is equivalent to
|
||||
// finishing, so the parent can refresh the status card.
|
||||
if (!next && step === 'backup') onEnrolled();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === 'qr' && 'Set up two-factor authentication'}
|
||||
{step === 'confirm' && 'Confirm your authenticator'}
|
||||
{step === 'backup' && 'Save your backup codes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enrol a time-based one-time password (TOTP) authenticator and save
|
||||
single-use backup codes.
|
||||
</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 === 'qr' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator.
|
||||
</p>
|
||||
<div className="flex justify-center rounded-md border border-card-border bg-card p-4 shadow-card-bevel">
|
||||
{otpauthUri
|
||||
? <QRCodeSVG value={otpauthUri} size={176} />
|
||||
: <div className="h-[176px] w-[176px] bg-muted/20 animate-pulse rounded" />
|
||||
}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret((v) => !v)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showSecret ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
Can't scan? Show secret key
|
||||
</button>
|
||||
{showSecret && (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-md border border-card-border bg-card px-3 py-2 font-mono text-xs tracking-wider break-all shadow-card-bevel">
|
||||
{formatSecret(secret) || '...'}
|
||||
</code>
|
||||
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
|
||||
<Copy className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<DialogTitle className="mt-1 font-display text-[1.75rem] italic leading-tight text-stat-value">
|
||||
{step === 'qr' && 'Pair your authenticator'}
|
||||
{step === 'confirm' && 'Confirm the pairing'}
|
||||
{step === 'backup' && 'Save your recovery codes'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enrol a time-based one-time password (TOTP) authenticator and save
|
||||
single-use backup codes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<StepRail step={step} />
|
||||
|
||||
<div className="px-6 py-5">
|
||||
{step === 'qr' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Scan the code with 1Password, Bitwarden, Google Authenticator, or any TOTP app.
|
||||
</p>
|
||||
<div className="flex justify-center rounded-md bg-background p-5 shadow-[inset_0_2px_6px_0_oklch(0_0_0/0.45)]">
|
||||
{otpauthUri ? (
|
||||
<QRCodeSVG value={otpauthUri} size={180} />
|
||||
) : (
|
||||
<div className="flex h-[180px] w-[180px] items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Secret · manual entry
|
||||
</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code className="flex-1 truncate rounded-md border border-card-border bg-background/60 px-3 py-2 font-mono text-xs tabular-nums tracking-[0.2em] text-stat-value shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.25)]">
|
||||
{formatSecret(secret) || '...'}
|
||||
</code>
|
||||
<Button type="button" size="icon" variant="outline" onClick={handleCopySecret} disabled={!secret}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-2 gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setStep('confirm')}
|
||||
disabled={!otpauthUri || loading}
|
||||
className="bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button type="button" onClick={() => setStep('confirm')} disabled={!otpauthUri || loading}>
|
||||
Next
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<form onSubmit={handleConfirm} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter the 6-digit code shown in your authenticator app to confirm enrolment.
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mfa-confirm-code">Verification code</Label>
|
||||
<Input
|
||||
id="mfa-confirm-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"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setStep('qr')} disabled={loading}>Back</Button>
|
||||
<Button type="submit" disabled={loading || code.length !== TOTP_LENGTH}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
{step === 'confirm' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Enter the 6-digit code shown in your authenticator to confirm the pairing.
|
||||
</p>
|
||||
<OtpDigitField
|
||||
id="mfa-confirm-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={() => setStep('qr')} disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'backup' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each code can be used once if your authenticator is unavailable. 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={handleCopyBackupCodes}>
|
||||
<Copy className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
|
||||
<Download className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={handleFinish}>I've saved these</Button>
|
||||
</DialogFooter>
|
||||
{step === 'backup' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm leading-snug text-stat-subtitle">
|
||||
Each code unlocks your account once if your authenticator is unavailable. 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={handleCopyBackupCodes}>
|
||||
<Copy className="h-4 w-4" strokeWidth={1.5} />
|
||||
Copy all
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="flex-1" onClick={handleDownloadBackupCodes}>
|
||||
<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 StepRail({ step }: { step: Step }) {
|
||||
const steps: { id: Step; label: string }[] = [
|
||||
{ id: 'qr', label: 'Pair' },
|
||||
{ id: 'confirm', label: 'Confirm' },
|
||||
{ id: 'backup', label: 'Archive' },
|
||||
];
|
||||
const activeIndex = steps.findIndex((s) => s.id === step);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 border-b border-card-border/60">
|
||||
{steps.map((s, i) => {
|
||||
const isActive = i === activeIndex;
|
||||
const isComplete = i < activeIndex;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center gap-2 px-4 py-2.5 font-mono text-[10px] uppercase tracking-[0.18em]',
|
||||
i < steps.length - 1 && 'border-r border-card-border/60',
|
||||
isActive ? 'text-brand' : isComplete ? 'text-stat-subtitle' : 'text-stat-subtitle/60',
|
||||
)}
|
||||
>
|
||||
{isComplete ? (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand" aria-hidden />
|
||||
) : (
|
||||
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span>
|
||||
)}
|
||||
<span>{s.label}</span>
|
||||
{isActive && (
|
||||
<span aria-hidden className="absolute inset-x-3 bottom-0 h-[2px] bg-brand" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user