Skip to contentVibraUI
Shared pagesinstalls at /two-factor

Two-factor setup

Turn on a second factor: a QR of the otpauth URL with the same secret printed beside it to type, a six-digit verify, and the recovery codes handed over once it is on.

Open the live page

The page is a server component: data.ts builds the otpauth:// URL from the same constants the secret is printed from, so the symbol and the typed secret can never disagree, and the QR is drawn on the server — the first paint already carries it. The secret is a fixed literal, deliberately: a demo that minted one per render would show a different symbol to the server and the browser, and a reader who scanned one and came back would be looking at a different account. A real setup mints one per enrolment, on the server, and hands it in the same way. Both routes onto the phone are offered, because a camera is not always usable: the symbol carries a real accessible name rather than aria-hidden, and the secret sits under it with a copy button. Verification goes through createAuthActions, which refuses anything that is not six digits before the adapter is asked at all, so a four-digit code is a message beside the field rather than a round trip; the mock then rejects 000000. The refusal is tied to the field by aria-describedby and paints the slots, which are decorative divs, from the form. Success replaces the whole setup step — there is nothing left to scan — and hands over the recovery codes, which are fixed for the same reason the secret is: a set regenerated per render is a set nobody can write down. Composes AuthFrame (block-local), Callout, QrCode, CopyButton, InputOTP, Label and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-two-factor

Needs the @vibra registry in your components.json — set it up once.

Source

app/two-factor/page.tsx
import Link from "next/link"

import { Callout } from "@/components/ui/callout"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { EnrolForm } from "./components/enrol-form"
import { ACCOUNT, BRAND, SIGN_IN_HREF, otpauthUrl } from "./data"

/**
 * Turn on a second factor: scan the symbol, type what the app shows, keep the
 * recovery codes. A server component — the QR is drawn on the server from a
 * fixed secret, so the first paint already carries it — and only the form is a
 * client island. The page renders outside AppShell, so the frame owns the
 * single `main`.
 */
export default function TwoFactorPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Two-factor authentication"
      description={
        <>
          Add a code from your phone to <span className="text-foreground">{ACCOUNT}</span>. It
          takes about a minute and you only do it once per device.
        </>
      }
      brand={BRAND}
      layout={layout}
      footer={
        <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
          Back to sign in
        </Link>
      }
    >
      <Callout className="text-xs">
        This demo runs on the mock adapter: any six digits work except{" "}
        <span className="font-mono">000000</span>.
      </Callout>

      <EnrolForm otpauth={otpauthUrl()} />
    </AuthFrame>
  )
}
app/two-factor/data.ts
/**
 * Everything this page reads. What it changes lives in `actions.ts` beside it,
 * where the adapter runs on the server; this file holds only the vocabulary the
 * page and its client island share.
 *
 * The secret is a fixed literal rather than a generated one, and deliberately
 * so: a demo that minted a new secret on every render would show a different QR
 * to the server and the browser, and a reader who scanned one and came back to
 * the page would be looking at a different account. A real setup mints one per
 * enrolment, on the server, and hands it here.
 */

/** The workspace this card is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }

export const SIGN_IN_HREF = "/sign-in"
export const SECURITY_HREF = "/settings/security"

/** Who the second factor is being turned on for. */
export const ACCOUNT = "ada@northwind.example"

/** The name an authenticator app files the account under. */
export const ISSUER = "Northwind"

/** Base32, no padding — the alphabet every authenticator reads. */
export const SECRET = "JBSWY3DPEHPK3PXPJBSWY3DP"

/** How many digits the code has, and how many slots the field draws. */
export const CODE_LENGTH = 6

/** How often the app rolls the code, in seconds. */
export const PERIOD_SECONDS = 30

/**
 * The URL an authenticator app expects behind a setup QR: the account it is
 * for, the secret, and the parameters the code is generated with. Built from
 * the constants above, so the symbol and the typed secret can never disagree.
 */
export function otpauthUrl(): string {
  const params = new URLSearchParams({
    secret: SECRET,
    issuer: ISSUER,
    algorithm: "SHA1",
    digits: String(CODE_LENGTH),
    period: String(PERIOD_SECONDS),
  })
  return `otpauth://totp/${ISSUER}:${ACCOUNT}?${params.toString()}`
}

/**
 * The codes that get someone back in when the phone is gone. Fixed, for the
 * same reason the secret is: a set regenerated per render would be a set the
 * reader could never write down.
 */
export const RECOVERY_CODES = [
  "4H2K-9QTM",
  "P7XD-2BLR",
  "MC63-VU8A",
  "R9WN-4JKE",
  "TZ51-HDQ7",
  "83YB-NXFS",
  "GK47-2MRV",
  "QD90-LTCB",
]
app/two-factor/actions.ts
"use server"

import { createAuthActions, mockAuthAdapter, type AuthResult } from "@/lib/auth-adapter"

/**
 * What this page changes. `createAuthActions` validates the submitted FormData
 * and only then calls the adapter, so pointing the page at a real backend is a
 * one-line change here: hand it your own adapter instead of the mock and
 * nothing above this file moves.
 *
 * The adapter stays on this side of the boundary. Its session is module state
 * and a real one holds a secret, so the form imports this function rather than
 * the adapter — `useActionState` takes a server action as it is.
 */
const actions = createAuthActions(mockAuthAdapter)

/** Confirms the six digits the authenticator app is showing right now. */
export async function verifyCode(formData: FormData): Promise<AuthResult<{ verified: true }>> {
  return actions.verifyCode(formData)
}
app/two-factor/components/auth-frame.tsx
import * as React from "react"

export type AuthLayout = "centered" | "split"

export type AuthFrameProps = {
  /** The page's only h1. */
  title: string
  description: React.ReactNode
  brand: { name: string; initial: string }
  /** Sits under the card, outside its frame — the way off this page. */
  footer?: React.ReactNode
  /**
   * Goes in the split panel, under the brand. Decoration inside decoration:
   * the panel is `aria-hidden`, and a "centered" frame has no panel to put it
   * in, so nothing here may be the only place the page says something.
   */
  aside?: React.ReactNode
  /** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
  layout?: AuthLayout
  children: React.ReactNode
}

/**
 * The frame this page sits in. Auth pages render outside AppShell, so nothing
 * else on the route owns a landmark: the frame carries the page's single
 * `main` and its single `h1`.
 *
 * `layout` belongs to the product this page is installed in rather than to the
 * page — a template says which one it wants, and the page forwards it. The
 * card is the same either way; "split" only sets a brand panel beside it, and
 * only from `lg`, where there is room for one. Below that the two layouts are
 * the same screen: a phone gets the card, never a panel stacked above it.
 *
 * Copied into each auth block rather than shared between them. A block
 * installs as a self-contained route, so it brings its own frame with it.
 */
export function AuthFrame({
  title,
  description,
  brand,
  footer,
  aside,
  layout = "centered",
  children,
}: AuthFrameProps) {
  const card = (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <div className="flex items-center justify-center gap-2">
        <span
          aria-hidden="true"
          className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
        >
          {brand.initial}
        </span>
        <span className="text-sm font-medium tracking-tight">{brand.name}</span>
      </div>

      <section className="flex flex-col gap-5 panel p-6">
        <header className="flex flex-col gap-1.5">
          <h1 className="type-display text-3xl text-pretty">{title}</h1>
          <p className="text-sm text-pretty text-muted-foreground">{description}</p>
        </header>
        {children}
      </section>

      {footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
    </div>
  )

  if (layout === "split") {
    return (
      <main
        data-slot="auth-frame"
        data-layout={layout}
        className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
      >
        {/* The panel stays in the accessibility tree: it carries the brand and
            whatever the page hands it through `aside` — sign-up's headline and
            highlights, for one — so only the mark and the hairline are hidden
            as decoration. A surface token rather than the primary colour, so it
            reads as a panel in both themes instead of inverting in the dark one. */}
        <aside
          data-slot="auth-frame-panel"
          className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
        >
          <span
            aria-hidden="true"
            className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
          >
            {brand.initial}
          </span>
          <div className="flex flex-col gap-3">
            <span className="type-display text-3xl">{brand.name}</span>
            <span aria-hidden="true" className="h-px w-16 bg-border" />
          </div>

          {aside}
        </aside>

        <div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
      </main>
    )
  }

  return (
    <main
      data-slot="auth-frame"
      data-layout={layout}
      className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
    >
      {card}
    </main>
  )
}
app/two-factor/components/enrol-form.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { ShieldCheckIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { type AuthResult } from "@/lib/auth-adapter"
import { Button, buttonVariants } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
import { Label } from "@/components/ui/label"
import { QrCode } from "@/components/ui/qr-code"

import { verifyCode } from "../actions"
import { CODE_LENGTH, RECOVERY_CODES, SECRET, SECURITY_HREF } from "../data"

const CODE_ID = "two-factor-code"
const MESSAGE_ID = "two-factor-error"

// Two groups of three, which is how a six-digit code is read aloud.
const FIRST_HALF = [0, 1, 2]
const SECOND_HALF = [3, 4, 5]

const SLOT_CLASS = "size-11 text-base"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the form,
 * and never on the form's own mount. `tabIndex={-1}` makes it a focus target
 * without putting it in the tab order, so a reader whose focus was on the
 * submit button is moved to what replaced it instead of being dropped at the
 * top of the document.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/**
 * Scan, type six digits, and — once the adapter agrees — the recovery codes.
 *
 * The symbol and the secret are the same string twice, so an app that cannot
 * use a camera is never stuck; both come from `data.ts`, which builds the
 * `otpauth://` URL from the same constants the secret is printed from. The
 * whole setup step is replaced by the confirmation on success, because there is
 * nothing left to scan, and the recovery codes are the one thing a reader has
 * to take away with them.
 */
export function EnrolForm({ otpauth }: { otpauth: string }) {
  const [result, formAction, pending] = React.useActionState<
    AuthResult<{ verified: true }> | null,
    FormData
  >((_previous, formData) => verifyCode(formData), null)

  const error = result && !result.ok ? result.error : null
  // Only an error that names the code belongs on the field. Anything else — a
  // rate limit, an outage — is about the attempt, not the six digits, and
  // reads above the button as a form-level alert rather than marking a code
  // that may be perfectly correct.
  const codeInvalid = error?.field === "code"

  if (result?.ok) {
    return (
      <div className="flex flex-col gap-5">
        <div className="flex flex-col items-start gap-3">
          <span
            aria-hidden="true"
            className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success"
          >
            <ShieldCheckIcon className="size-4" />
          </span>
          <div className="flex flex-col gap-1.5">
            <ConfirmationHeading>Two-factor is on</ConfirmationHeading>
            <p role="status" className="text-sm text-pretty text-muted-foreground">
              Signing in will ask for a code from now on. Keep these recovery codes somewhere
              other than the phone — each one works once, and they are the only way back in if
              the device is lost.
            </p>
          </div>
        </div>

        <ul
          aria-label="Recovery codes"
          className="grid grid-cols-2 gap-x-4 gap-y-1.5 panel p-3 font-mono text-sm tabular-nums"
        >
          {RECOVERY_CODES.map((code) => (
            <li key={code}>{code}</li>
          ))}
        </ul>

        <Link href={SECURITY_HREF} className={buttonVariants()}>
          Back to security settings
        </Link>
      </div>
    )
  }

  return (
    <div className="flex flex-col gap-5">
      <div className="flex flex-col items-center gap-3">
        {/* Not decoration: it is one of the two ways to enrol, so it carries a
            real name rather than aria-hidden. */}
        <QrCode
          value={otpauth}
          size={168}
          label="Scan this with your authenticator app"
          className="panel p-3"
        />
        <div className="flex w-full flex-col gap-1.5">
          <span className="text-xs text-muted-foreground">
            No camera? Type this secret into the app instead.
          </span>
          <div className="flex items-center gap-2 frame px-3 py-2">
            <code className="min-w-0 flex-1 font-mono text-xs break-all">{SECRET}</code>
            <CopyButton value={SECRET} label="Copy the setup secret" />
          </div>
        </div>
      </div>

      <form action={formAction} noValidate className="flex flex-col gap-4">
        <div className="flex flex-col gap-2">
          <Label htmlFor={CODE_ID}>Verification code</Label>
          {/* The slots are decorative divs, so the invalid state is painted
              through them from here rather than set on each one. */}
          <div className={cn(codeInvalid && "[&_[data-slot=input-otp-slot]]:border-danger")}>
            <InputOTP
              id={CODE_ID}
              name="code"
              maxLength={CODE_LENGTH}
              autoComplete="one-time-code"
              containerClassName="justify-start"
              aria-invalid={codeInvalid || undefined}
              aria-describedby={codeInvalid ? MESSAGE_ID : undefined}
            >
              <InputOTPGroup>
                {FIRST_HALF.map((index) => (
                  <InputOTPSlot key={index} index={index} className={SLOT_CLASS} />
                ))}
              </InputOTPGroup>
              <InputOTPSeparator />
              <InputOTPGroup>
                {SECOND_HALF.map((index) => (
                  <InputOTPSlot key={index} index={index} className={SLOT_CLASS} />
                ))}
              </InputOTPGroup>
            </InputOTP>
          </div>
        </div>

        {codeInvalid ? (
          <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}

        {error && !codeInvalid ? (
          <p role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}

        <Button type="submit" disabled={pending}>
          {pending ? "Checking…" : "Turn on two-factor"}
        </Button>
      </form>
    </div>
  )
}