/two-factorTwo-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.
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
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>
)
}Install
npx shadcn@latest add @vibra/auth-two-factorNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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",
]"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)
}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>
)
}"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>
)
}