"use client"; import { useState } from "react"; import { ArrowLeft, Check, Copy, Download, Lock, Shield } from "@/components/icons/platform-icons"; import { cn } from "@/lib/utils"; import { SETTINGS_PANEL_INSET } from "@/modules/settings/components/panel/surface"; import { SectionDivider } from "@/modules/settings/components/panel/controls"; import type { TwoFactorErrorCode, TwoFactorStatus, } from "@/lib/shared/contracts/auth"; import { TwoFactorApiError, useDisableTwoFactor, useStartTwoFactorSetup, useVerifyTwoFactor, } from "@/modules/settings/hooks/useTwoFactor"; type WizardStep = "idle" | "scan" | "verify" | "backup-codes"; type TwoFactorCardProps = { status: TwoFactorStatus; isDemoMode?: boolean; }; const codeInputCls = "h-10 w-full rounded-lg border border-glass-border bg-background/55 px-3 font-mono text-sm text-foreground tracking-[0.2em] text-center focus:border-primary/50 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"; function describeError(error: unknown): string { if (error instanceof TwoFactorApiError) { return mapErrorCodeToMessage(error.code, error.message); } if (error instanceof Error) return error.message; return "Something went wrong"; } function mapErrorCodeToMessage( code: TwoFactorErrorCode | "unknown", fallback: string, ): string { switch (code) { case "invalid_totp": return "That code doesn't match. Check the time on your device and try again."; case "invalid_backup_code": return "That backup code isn't valid or has already been used."; case "too_many_attempts": return "Too many attempts. Wait a moment before trying again."; case "already_enabled": return "Two-factor is already enabled."; case "not_enabled": return "Two-factor isn't enabled on this account."; case "no_pending_enrollment": return "Enrolment expired. Start again."; default: return fallback; } } function formatEnrolledAt(iso: string | null): string { if (!iso) return "—"; try { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } catch { return iso; } } function downloadBackupCodes(codes: string[]) { const text = [ "Homeio — Two-Factor Backup Codes", "Generated: " + new Date().toISOString(), "", "Each code may be used once. Store these somewhere safe — they are the", "only way to sign in if you lose access to your authenticator app.", "", ...codes, "", ].join("\n"); const blob = new Blob([text], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "homeio-2fa-backup-codes.txt"; a.click(); URL.revokeObjectURL(url); } export function TwoFactorCard({ status, isDemoMode = false }: TwoFactorCardProps) { const [step, setStep] = useState("idle"); const [setupData, setSetupData] = useState<{ secret: string; otpAuthUrl: string; qrCodeSvg: string; } | null>(null); const [verifyCode, setVerifyCode] = useState(""); const [disableCode, setDisableCode] = useState(""); const [backupCodes, setBackupCodes] = useState([]); const [acknowledgedBackup, setAcknowledgedBackup] = useState(false); const [showDisable, setShowDisable] = useState(false); const [secretCopied, setSecretCopied] = useState(false); const startSetup = useStartTwoFactorSetup(); const verify = useVerifyTwoFactor(); const disable = useDisableTwoFactor(); async function handleEnableClick() { if (isDemoMode) return; try { const data = await startSetup.mutateAsync(); setSetupData(data); setVerifyCode(""); setAcknowledgedBackup(false); setStep("scan"); } catch { // mutation surface keeps the error } } async function handleVerifySubmit() { if (!verifyCode.trim()) return; try { const result = await verify.mutateAsync({ code: verifyCode.trim() }); setBackupCodes(result.backupCodes); setVerifyCode(""); setStep("backup-codes"); } catch { // error surface kept by mutation } } async function handleDisableSubmit() { if (!disableCode.trim()) return; try { await disable.mutateAsync({ code: disableCode.trim() }); setDisableCode(""); setShowDisable(false); // The server clears the session cookie. Push to /login so the user // doesn't sit on a stale screen until the next request 401s. window.location.href = "/login"; } catch { // surface kept by mutation } } function cancelWizard() { setStep("idle"); setSetupData(null); setVerifyCode(""); setBackupCodes([]); setAcknowledgedBackup(false); startSetup.reset(); verify.reset(); } function finishBackupStep() { setBackupCodes([]); setAcknowledgedBackup(false); setSetupData(null); setStep("idle"); } async function copySecret() { if (!setupData) return; try { await navigator.clipboard.writeText(setupData.secret); setSecretCopied(true); window.setTimeout(() => setSecretCopied(false), 1500); } catch { // Ignore — fallback is the visible secret string. } } async function copyAllBackupCodes() { try { await navigator.clipboard.writeText(backupCodes.join("\n")); } catch { // Ignore. } } return ( <> {step === "idle" ? (
Authenticator app

{status.enabled ? `Enabled on ${formatEnrolledAt(status.enrolledAt)}. A 6-digit code is required at sign-in.` : "Add a second step to sign-in using an authenticator app (Google Authenticator, 1Password, Aegis, …)."}

{status.enabled ? ( ) : ( )}
{startSetup.error ? (

{describeError(startSetup.error)}

) : null} {status.enabled && showDisable ? (
setDisableCode(event.target.value)} inputMode="text" autoComplete="one-time-code" placeholder="123456 or backup code" disabled={disable.isPending} className={codeInputCls} /> {disable.error ? (

{describeError(disable.error)}

) : null}

You'll be signed out and need to sign in again.

) : null}
) : null} {step === "scan" && setupData ? (
the // server controls end-to-end. Rendering as HTML is intentional. dangerouslySetInnerHTML={{ __html: setupData.qrCodeSvg }} />

Scan with your authenticator app

Add a new account and scan this QR. Don't have a camera? Enter the key below manually.

{setupData.secret}
) : null} {step === "verify" ? (

Enter the 6-digit code

Type the current code from your authenticator app to confirm.

setVerifyCode(event.target.value.replace(/\s+/g, ""))} inputMode="numeric" autoComplete="one-time-code" placeholder="123456" disabled={verify.isPending} className={codeInputCls} autoFocus onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); void handleVerifySubmit(); } }} /> {verify.error ? (

{describeError(verify.error)}

) : null}
) : null} {step === "backup-codes" ? (

Save your backup codes

These ten codes are the only way to sign in if you lose your authenticator. Each works once. We can't show them again.

{backupCodes.map((code) => ( {code} ))}
) : null} ); } function StatusBadge({ enabled }: { enabled: boolean }) { if (enabled) { return ( Enabled ); } return ( Disabled ); }