import { useRef, useState } from 'react'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; import { Check, Copy, Download } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { copyToClipboard } from '@/lib/clipboard'; import { TOTP_LENGTH, normalizeTotpInput } from '@/lib/mfa'; import { OtpDigitField } from '@/components/auth/OtpDigitField'; import { ErrorRail } from '@/components/auth/ErrorRail'; import { BackupCodeTicket } from './BackupCodeTicket'; interface MfaBackupCodesDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onRegenerated: () => void; } type Step = 'confirm' | 'show'; export function MfaBackupCodesDialog({ open, onOpenChange, onRegenerated }: MfaBackupCodesDialogProps) { const [step, setStep] = useState('confirm'); 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([]); const submittedRef = useRef(false); const resetState = () => { 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', { method: 'POST', localOnly: true, body: JSON.stringify({ code: valueToSubmit }), }); const data = await res.json().catch(() => ({})); 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 handleCodeChange = (raw: string) => { const normalized = normalizeTotpInput(raw); setCode(normalized); if (normalized.length < TOTP_LENGTH) { submittedRef.current = false; if (confirmState === 'error') setConfirmState('idle'); } if ( normalized.length === TOTP_LENGTH && !loading && !submittedRef.current ) { submittedRef.current = true; requestAnimationFrame(() => { void submitRegenerate(normalized); }); } }; const handleCopy = async () => { try { await copyToClipboard(backupCodes.join('\n')); toast.success('Backup codes copied'); } catch { toast.error('Could not copy to clipboard'); } }; const handleDownload = () => { const blob = new Blob([ 'Sencho backup codes\n', 'Each code can be used once. Keep this file somewhere safe.\n\n', backupCodes.join('\n'), '\n', ], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'sencho-backup-codes.txt'; a.click(); URL.revokeObjectURL(url); }; const handleFinish = () => { resetState(); onOpenChange(false); onRegenerated(); }; return ( { if (!next) { if (step === 'show') onRegenerated(); resetState(); } onOpenChange(next); }} > {step === 'confirm' && ( <>

Enter a code from your authenticator to generate a new set. The previous codes stop working immediately.

{error && {error}}
onOpenChange(false)} disabled={loading}> Cancel } primary={null} /> )} {step === 'show' && ( <> Previous codes have been invalidated.

Each code can be used once. Store them safely. They will not be shown again.

Done } /> )}
); } function WarningRail({ children }: { children: React.ReactNode }) { return (
{children}
); }