zazzy
Blocks

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

Two-factor authentication
Add a time-based one-time password (TOTP) from an authenticator app as a second step when you sign in.
"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&apos;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-card

Run 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} />;

On this page