import { useEffect, useRef, useState } from 'react'; import { useAuth } from '@/context/AuthContext'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Loader2 } from 'lucide-react'; import { BACKUP_CODE_DISPLAY_LENGTH, TOTP_LENGTH, normalizeBackupCodeInput, normalizeTotpInput, } from '@/lib/mfa'; import { AuthCanvas } from '@/components/auth/AuthCanvas'; import { AuthStepHeader } from '@/components/auth/AuthStepHeader'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; import { ErrorRail } from '@/components/auth/ErrorRail'; type FieldState = 'idle' | 'loading' | 'error' | 'success'; function formatSeconds(total: number): string { const mm = Math.floor(total / 60).toString().padStart(2, '0'); const ss = Math.floor(total % 60).toString().padStart(2, '0'); return `${mm}:${ss}`; } export function MfaChallenge({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) { const { submitMfa, cancelMfa } = useAuth(); const [display, setDisplay] = useState(''); const [raw, setRaw] = useState(''); const [error, setError] = useState(''); const [fieldState, setFieldState] = useState('idle'); const [useBackup, setUseBackup] = useState(false); const [retrySeconds, setRetrySeconds] = useState(0); const submittedRef = useRef(false); useEffect(() => { if (retrySeconds <= 0) return; const id = window.setInterval(() => { setRetrySeconds((s) => (s <= 1 ? 0 : s - 1)); }, 1000); return () => window.clearInterval(id); }, [retrySeconds]); const runSubmit = async (valueToSubmit: string) => { setError(''); setFieldState('loading'); const result = await submitMfa(valueToSubmit, { isBackupCode: useBackup }); if (!result.success) { if (result.retryAfter && result.retryAfter > 0) { setRetrySeconds(Math.ceil(result.retryAfter)); setError(''); } else { setError(result.error || 'Verification failed'); } setDisplay(''); setRaw(''); setFieldState('error'); submittedRef.current = false; window.setTimeout(() => setFieldState('idle'), 600); return; } setFieldState('success'); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (fieldState === 'loading' || !raw) return; submittedRef.current = true; void runSubmit(raw); }; const handleOtpChange = (value: string) => { const normalized = normalizeTotpInput(value); setDisplay(normalized); setRaw(normalized); if (normalized.length < TOTP_LENGTH) { submittedRef.current = false; if (fieldState === 'error') setFieldState('idle'); } if ( normalized.length === TOTP_LENGTH && fieldState !== 'loading' && !submittedRef.current ) { submittedRef.current = true; requestAnimationFrame(() => { void runSubmit(normalized); }); } }; const handleBackupChange = (value: string) => { const next = normalizeBackupCodeInput(value); setDisplay(next.display); setRaw(next.raw); if (next.raw.length < 10) submittedRef.current = false; if (fieldState === 'error') setFieldState('idle'); }; const handleToggleBackup = () => { setUseBackup((v) => !v); setDisplay(''); setRaw(''); setError(''); setFieldState('idle'); submittedRef.current = false; }; const throttled = retrySeconds > 0; return (
Console · Verify
} >
{throttled ? ( ) : useBackup ? (
Backup code · 10 chars 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" />
{error && {error}}
) : (
{error && {error}}
)}
); } function ThrottleTile({ seconds }: { seconds: number }) { return (
Retry in {formatSeconds(seconds)}
Rate limited
); } function ModeToggle({ useBackup, onToggle, disabled, }: { useBackup: boolean; onToggle: () => void; disabled: boolean; }) { return ( ); }