import { useEffect, useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; 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 { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; interface MfaEnrollDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onEnrolled: () => void; } 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(); } export function MfaEnrollDialog({ open, onOpenChange, onEnrolled }: MfaEnrollDialogProps) { const [step, setStep] = useState('qr'); 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 [backupCodes, setBackupCodes] = useState([]); // When the dialog opens, start enrolment so the QR is ready immediately. useEffect(() => { if (!open) return; let cancelled = false; setStep('qr'); setCode(''); setError(''); setBackupCodes([]); setShowSecret(false); setLoading(true); apiFetch('/auth/mfa/enroll/start', { method: 'POST', localOnly: true }) .then(async (r) => { const data = await r.json().catch(() => ({})); if (cancelled) return; if (!r.ok) { toast.error(data?.error || 'Failed to start enrolment'); onOpenChange(false); return; } setOtpauthUri(data.otpauthUri); setSecret(data.secret); }) .catch((e) => { if (cancelled) return; toast.error(e?.message || 'Failed to start enrolment'); onOpenChange(false); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [open, onOpenChange]); const handleConfirm = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { const res = await apiFetch('/auth/mfa/enroll/confirm', { method: 'POST', localOnly: true, body: JSON.stringify({ code }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setError(data?.error || 'Verification failed'); return; } setBackupCodes(data.backupCodes || []); setStep('backup'); } catch (err) { setError((err as Error)?.message || 'Verification failed'); } finally { setLoading(false); } }; const handleCopySecret = async () => { try { await navigator.clipboard.writeText(secret); toast.success('Secret copied to clipboard'); } catch { toast.error('Could not copy to clipboard'); } }; const handleCopyBackupCodes = async () => { try { await navigator.clipboard.writeText(backupCodes.join('\n')); toast.success('Backup codes copied'); } catch { toast.error('Could not copy to clipboard'); } }; const handleDownloadBackupCodes = () => { 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 = () => { onOpenChange(false); onEnrolled(); }; return ( { // 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); }} > {step === 'qr' && 'Set up two-factor authentication'} {step === 'confirm' && 'Confirm your authenticator'} {step === 'backup' && 'Save your backup codes'} Enrol a time-based one-time password (TOTP) authenticator and save single-use backup codes. {step === 'qr' && (

Scan the QR code with an authenticator app such as 1Password, Bitwarden, or Google Authenticator.

{otpauthUri ? :
}
{showSecret && (
{formatSecret(secret) || '...'}
)}
)} {step === 'confirm' && (

Enter the 6-digit code shown in your authenticator app to confirm enrolment.

setCode(e.target.value)} className="font-mono tabular-nums tracking-widest text-center" placeholder="123456" />
{error &&
{error}
}
)} {step === 'backup' && (

Each code can be used once if your authenticator is unavailable. Store them somewhere safe; they will not be shown again.

{backupCodes.map((c) => (
{c}
))}
)}
); }