Two-factor setup card
A settings card to enable or disable TOTP two-factor authentication, wired to the Better Auth client with an enrollment QR and recovery codes.
An account-settings card for TOTP two-factor authentication. When 2FA is off it
walks the enrollment flow — account password → scan the QR (or copy the manual
key) and save the recovery codes → confirm a first code to commit — and when it's
on it offers a password-gated turn-off. It's composed over the same Better Auth
client twoFactor surface (@/lib/auth-client) the reference app's account
settings use, plus react-qr-code for the enrollment QR.
The card takes its initial on/off state as a prop (initialEnabled, the server's
User.twoFactorEnabled) and only touches the network on a user action, so it
mounts without a request.
Requires: auth
Preview
"use client";import { zodResolver } from "@hookform/resolvers/zod";import { useState } from "react";import { useForm } from "react-hook-form";import QRCode from "react-qr-code";import { Button } from "@/components/ui/button";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { Input } from "@/components/ui/input";import { Label } from "@/components/ui/label";import { passwordConfirmSchema, totpCodeSchema, type PasswordConfirmValues, type TotpCodeValues,} from "@/features/auth/lib/schemas";import { twoFactor } from "@/lib/auth-client";/** * A drop-in account-settings card for TOTP two-factor authentication, composed * over the same Better Auth client `twoFactor` surface the reference app's * `features/auth/components/two-factor-section.tsx` uses. Enabling generates a * secret + recovery codes but only commits once the user confirms a first code * (a half-finished enrollment never locks anyone out); every state change * re-authenticates with the account password, matching the sign-in threat model. * * The server truth is `User.twoFactorEnabled`, passed as `initialEnabled` — the * card mounts request-free (no query on mount) and only touches the network on a * user action, so it renders in a backend-free preview without a request to * intercept. Local state tracks the enabled flag through the in-page flow; * `onChange` lets a parent re-sync its server-rendered shell after a transition. */export function TwoFactorSetupCard({ initialEnabled, onChange,}: { initialEnabled: boolean; onChange?: (enabled: boolean) => void;}) { const [enabled, setEnabled] = useState(initialEnabled); const set = (next: boolean) => { setEnabled(next); onChange?.(next); }; return ( <Card> <CardHeader> <CardTitle>Two-factor authentication</CardTitle> <CardDescription> {enabled ? "Two-factor authentication is on." : "Add a time-based one-time password (TOTP) from an authenticator app as a second step when you sign in."} </CardDescription> </CardHeader> {enabled ? ( <DisablePanel onDisabled={() => set(false)} /> ) : ( <Enrollment onEnabled={() => set(true)} /> )} </Card> );}/** Off → generate secret (password) → confirm a TOTP code → on. */function Enrollment({ onEnabled }: { onEnabled: () => void }) { const [setup, setSetup] = useState<{ totpURI: string; backupCodes: string[]; } | null>(null); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<PasswordConfirmValues>({ resolver: zodResolver(passwordConfirmSchema), }); const [formError, setFormError] = useState<string | null>(null); const start = handleSubmit(async (values) => { setFormError(null); const { data, error } = await twoFactor.enable({ password: values.password, }); if (error || !data) { setFormError("Could not start setup. Check your password and try again."); return; } setSetup({ totpURI: data.totpURI, backupCodes: data.backupCodes }); }); if (setup) { return <ConfirmEnrollment setup={setup} onConfirmed={onEnabled} />; } return ( <form onSubmit={start} noValidate> <CardContent className="flex flex-col gap-1.5"> <Label htmlFor="enable-2fa-password">Account password</Label> <Input id="enable-2fa-password" type="password" autoComplete="current-password" {...register("password")} /> {errors.password && ( <p className="text-sm text-destructive">{errors.password.message}</p> )} {formError && ( <p role="alert" className="text-sm text-destructive"> {formError} </p> )} </CardContent> <CardFooter> <Button type="submit" disabled={isSubmitting}> {isSubmitting ? "Starting…" : "Enable two-factor authentication"} </Button> </CardFooter> </form> );}/** Show the QR + recovery codes, then verify the first TOTP code to finish. */function ConfirmEnrollment({ setup, onConfirmed,}: { setup: { totpURI: string; backupCodes: string[] }; onConfirmed: () => void;}) { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<TotpCodeValues>({ resolver: zodResolver(totpCodeSchema) }); const [formError, setFormError] = useState<string | null>(null); // The manual-entry secret is the `secret` param of the otpauth:// URI — the // same value the QR encodes, shown for apps that can't scan. const manualKey = new URL(setup.totpURI).searchParams.get("secret") ?? ""; const confirm = handleSubmit(async (values) => { setFormError(null); const { error } = await twoFactor.verifyTotp({ code: values.code }); if (error) { setFormError("That code is incorrect or has expired. Try again."); return; } onConfirmed(); }); return ( <form onSubmit={confirm} noValidate> <CardContent className="flex flex-col gap-4"> <div className="flex flex-col gap-2"> <p className="text-sm text-muted-foreground"> Scan this QR code with your authenticator app, then enter the 6-digit code it shows to finish. </p> <div className="w-fit rounded-lg bg-white p-3"> <QRCode value={setup.totpURI} size={160} /> </div> <p className="text-xs text-muted-foreground"> Can't scan? Enter this key manually:{" "} <span className="font-mono break-all">{manualKey}</span> </p> </div> <RecoveryCodes codes={setup.backupCodes} /> <div className="flex flex-col gap-1.5"> <Label htmlFor="confirm-totp">Authentication code</Label> <Input id="confirm-totp" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" {...register("code")} /> {errors.code && ( <p className="text-sm text-destructive">{errors.code.message}</p> )} {formError && ( <p role="alert" className="text-sm text-destructive"> {formError} </p> )} </div> </CardContent> <CardFooter> <Button type="submit" disabled={isSubmitting}> {isSubmitting ? "Verifying…" : "Verify and turn on"} </Button> </CardFooter> </form> );}/** On → disable 2FA (password). */function DisablePanel({ onDisabled }: { onDisabled: () => void }) { const { register, handleSubmit, reset, formState: { errors, isSubmitting }, } = useForm<PasswordConfirmValues>({ resolver: zodResolver(passwordConfirmSchema), }); const [formError, setFormError] = useState<string | null>(null); const onSubmit = handleSubmit(async (values) => { setFormError(null); const { error } = await twoFactor.disable({ password: values.password }); if (error) { setFormError( "Could not disable two-factor authentication. Check your password.", ); return; } reset({ password: "" }); onDisabled(); }); return ( <form onSubmit={onSubmit} noValidate> <CardContent className="flex flex-col gap-1.5"> <Label htmlFor="disable-2fa-password">Account password</Label> <Input id="disable-2fa-password" type="password" autoComplete="current-password" {...register("password")} /> {errors.password && ( <p className="text-sm text-destructive">{errors.password.message}</p> )} {formError && ( <p role="alert" className="text-sm text-destructive"> {formError} </p> )} </CardContent> <CardFooter> <Button type="submit" variant="destructive" disabled={isSubmitting}> {isSubmitting ? "Turning off…" : "Turn off two-factor authentication"} </Button> </CardFooter> </form> );}/** Monospace list of one-time recovery codes to save before finishing setup. */function RecoveryCodes({ codes }: { codes: string[] }) { return ( <div className="flex flex-col gap-2 rounded-lg border p-4"> <p className="text-sm font-medium">Save your recovery codes</p> <p className="text-xs text-muted-foreground"> Store these somewhere safe. Each one lets you sign in once if you lose your authenticator. </p> <ul className="mt-1 grid grid-cols-2 gap-1 font-mono text-sm"> {codes.map((code) => ( <li key={code} className="tracking-wider"> {code} </li> ))} </ul> </div> );}Offline preview
The card is prop-driven, so it renders fully offline. The preview shows the disabled state (the enrollment entry point); starting setup, showing the QR, and confirming a code all call the Better Auth client, which only runs in a real app.
Install
npx create-saas-starter add two-factor-setup-cardRun it from your app's root. add only writes new files — wiring is up to you.
Pass the server's current 2FA state so the card mounts request-free:
import { TwoFactorSetupCard } from "@/components/blocks/two-factor-setup-card/two-factor-setup-card";
// e.g. from a server component that has the session user:
<TwoFactorSetupCard initialEnabled={user.twoFactorEnabled} />;