import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Check, Copy, Loader2, ShieldCheck, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, } from '@/components/ui/dialog'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import api from '@/lib/api'; import { getErrorMessage } from '@/lib/api'; import type { TotpSetupResponse } from '@/types'; type TotpSetupState = 'idle' | 'setup' | 'confirm' | 'backup_codes' | 'enabled'; export default function TotpSetupSection() { // ── Password change state ── const [passwordForm, setPasswordForm] = useState({ oldPassword: '', newPassword: '', confirmPassword: '', }); const [isChangingPassword, setIsChangingPassword] = useState(false); // ── TOTP state ── const [totpSetupState, setTotpSetupState] = useState('idle'); const [qrCodeBase64, setQrCodeBase64] = useState(''); const [totpSecret, setTotpSecret] = useState(''); const [totpConfirmCode, setTotpConfirmCode] = useState(''); const [backupCodes, setBackupCodes] = useState([]); const [isTotpSetupPending, setIsTotpSetupPending] = useState(false); const [isTotpConfirmPending, setIsTotpConfirmPending] = useState(false); // ── Disable / Regenerate dialog state ── const [disableDialogOpen, setDisableDialogOpen] = useState(false); const [regenDialogOpen, setRegenDialogOpen] = useState(false); const [dialogPassword, setDialogPassword] = useState(''); const [dialogCode, setDialogCode] = useState(''); const [isDialogPending, setIsDialogPending] = useState(false); // On mount: check TOTP status to set initial state useEffect(() => { api .get<{ enabled: boolean }>('/auth/totp/status') .then(({ data }) => { setTotpSetupState(data.enabled ? 'enabled' : 'idle'); }) .catch(() => { // If endpoint not yet available, default to idle setTotpSetupState('idle'); }); }, []); // ── Password change ── const handlePasswordChange = async () => { const { oldPassword, newPassword, confirmPassword } = passwordForm; if (!oldPassword || !newPassword || !confirmPassword) { toast.error('All password fields are required'); return; } if (newPassword !== confirmPassword) { toast.error('New passwords do not match'); return; } if (newPassword.length < 12) { toast.error('Password must be at least 12 characters'); return; } const hasLetter = /[a-zA-Z]/.test(newPassword); const hasNonLetter = /[^a-zA-Z]/.test(newPassword); if (!hasLetter || !hasNonLetter) { toast.error('Password must contain at least one letter and one non-letter character'); return; } setIsChangingPassword(true); try { await api.post('/auth/change-password', { old_password: oldPassword, new_password: newPassword, }); setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' }); toast.success('Password changed successfully'); } catch (error) { toast.error(getErrorMessage(error, 'Failed to change password')); } finally { setIsChangingPassword(false); } }; // ── TOTP setup ── const handleBeginTotpSetup = async () => { setIsTotpSetupPending(true); try { const { data } = await api.post('/auth/totp/setup'); setQrCodeBase64(data.qr_code_base64); setTotpSecret(data.secret); setBackupCodes(data.backup_codes); setTotpSetupState('setup'); } catch (error) { toast.error(getErrorMessage(error, 'Failed to begin TOTP setup')); } finally { setIsTotpSetupPending(false); } }; const handleTotpConfirm = async () => { if (!totpConfirmCode || totpConfirmCode.length !== 6) { toast.error('Enter a 6-digit code'); return; } setIsTotpConfirmPending(true); try { const { data } = await api.post<{ backup_codes: string[] }>('/auth/totp/confirm', { code: totpConfirmCode, }); setBackupCodes(data.backup_codes); setTotpConfirmCode(''); setTotpSetupState('backup_codes'); } catch (error) { toast.error(getErrorMessage(error, 'Invalid code — try again')); } finally { setIsTotpConfirmPending(false); } }; const handleCopyBackupCodes = async () => { try { await navigator.clipboard.writeText(backupCodes.join('\n')); toast.success('Backup codes copied'); } catch { toast.error('Failed to copy codes'); } }; const handleBackupCodesConfirmed = () => { setBackupCodes([]); setQrCodeBase64(''); setTotpSecret(''); setTotpSetupState('enabled'); }; // ── Disable TOTP ── const handleDisableConfirm = async () => { if (!dialogPassword || !dialogCode) { toast.error('Password and code are required'); return; } setIsDialogPending(true); try { await api.post('/auth/totp/disable', { password: dialogPassword, code: dialogCode }); setDisableDialogOpen(false); setDialogPassword(''); setDialogCode(''); setTotpSetupState('idle'); toast.success('Two-factor authentication disabled'); } catch (error) { toast.error(getErrorMessage(error, 'Failed to disable TOTP')); } finally { setIsDialogPending(false); } }; // ── Regenerate backup codes ── const handleRegenConfirm = async () => { if (!dialogPassword || !dialogCode) { toast.error('Password and code are required'); return; } setIsDialogPending(true); try { const { data } = await api.post<{ backup_codes: string[] }>( '/auth/totp/backup-codes/regenerate', { password: dialogPassword, code: dialogCode } ); setBackupCodes(data.backup_codes); setRegenDialogOpen(false); setDialogPassword(''); setDialogCode(''); setTotpSetupState('backup_codes'); toast.success('New backup codes generated'); } catch (error) { toast.error(getErrorMessage(error, 'Failed to regenerate backup codes')); } finally { setIsDialogPending(false); } }; const closeDialog = () => { setDisableDialogOpen(false); setRegenDialogOpen(false); setDialogPassword(''); setDialogCode(''); }; return ( <>
Authentication Manage your password and two-factor authentication
{/* Subsection A: Change Password */}

Change Password

setPasswordForm({ ...passwordForm, oldPassword: e.target.value })} autoComplete="current-password" />
setPasswordForm({ ...passwordForm, newPassword: e.target.value })} autoComplete="new-password" />
setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })} autoComplete="new-password" />
{/* Subsection B: TOTP MFA Setup */}
{totpSetupState === 'idle' && (

Two-Factor Authentication

Add an extra layer of security with an authenticator app

)} {totpSetupState === 'setup' && (

Scan with your authenticator app

TOTP QR code — scan with your authenticator app

Can't scan? Enter this code manually:

{totpSecret}
)} {totpSetupState === 'confirm' && (

Verify your authenticator app

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

setTotpConfirmCode(e.target.value.replace(/\D/g, ''))} className="text-center tracking-widest text-lg" autoFocus autoComplete="one-time-code" onKeyDown={(e) => { if (e.key === 'Enter') handleTotpConfirm(); }} />
)} {totpSetupState === 'backup_codes' && (

These {backupCodes.length} codes can each be used once if you lose access to your authenticator app. Store them somewhere safe — they will not be shown again.

{backupCodes.map((code, i) => ( {code} ))}
)} {totpSetupState === 'enabled' && (

Two-Factor Authentication

Your account is protected with an authenticator app

)}
{/* Disable TOTP Dialog */} Disable Two-Factor Authentication Enter your password and a current authenticator code to disable MFA.
setDialogPassword(e.target.value)} autoComplete="current-password" />
setDialogCode(e.target.value.replace(/\D/g, ''))} className="text-center tracking-widest" autoComplete="one-time-code" />
{/* Regenerate Backup Codes Dialog */} Generate New Backup Codes Your existing backup codes will be invalidated. Enter your password and a current authenticator code to continue.
setDialogPassword(e.target.value)} autoComplete="current-password" />
setDialogCode(e.target.value.replace(/\D/g, ''))} className="text-center tracking-widest" autoComplete="one-time-code" />
); }