Blocks

Two-Factor Setup

The enable-2FA flow built around its one irreversible step — recovery codes are rendered once, the finish button refuses to fire until they are saved, and finishing wipes them from the DOM.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowLeft,
  Check,
  Copy,
  Download,
  KeyRound,
  LoaderCircle,
  ShieldCheck,
  Smartphone,
  TriangleAlert,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/two-factor-setup.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "TwoFactorSetup" block (lucide-react icons,
plus a segmented OTP field and a QR renderer). It generates nothing: the shared
secret, the otpauth:// URI and the recovery codes are server-side facts handed in
as props.

Contract
- forwardRef <section> extending HTMLAttributes, merging className via cn().
- authenticator?: { secret: string; uri: string } — Base32 secret + the otpauth
  URI the QR encodes.
- sms?: { phone: string; onSend: () => Promise<void> } — masked destination plus
  the sender. Grouping the data with its handler is deliberate: there is no
  separate `methods` prop, so an offered method can never lack what it needs.
  Two present objects means a picker step; one means the picker does not exist.
- recoveryCodes: string[] — one-time codes. An empty array is legal and drops the
  acknowledgement gate rather than deadlocking the finish button.
- maxAttempts?: number (default 5, floored and clamped to at least 1).
- onVerify: ({ method, code }) => Promise of { status: "verified" } or
  { status: "invalid"; message?: string }.
- onComplete?, onCancel?, onAttemptsExhausted? — each fires at most once.
- heading?, description?, downloadFileName? (default "recovery-codes.txt").

Behavior
- Steps: method (only when both are offered), enroll, verify, recovery, done. A
  stepper lists them; the current one carries aria-current="step", completed ones
  are buttons that walk back, future ones are inert text.
- Authenticator enrolment: the QR next to three numbered instructions, then the
  manual key for a camera that will not focus. The key is rendered as one span
  per four characters with NO whitespace between them, so selecting the element
  yields the unbroken key; a sibling sr-only node repeats it comma-separated so a
  screen reader reads it in the same four-character chunks the eye sees.
- SMS enrolment: the masked number and a send button wired to sms.onSend. The
  screen only advances once that promise resolves; a rejection is reported and
  stays on the step. The verify screen keeps a "send a new code" link on the same
  handler, guarded by an in-flight ref rather than a cooldown.
- Verify: a 6-cell numeric OTP field. Pasting six digits into the first cell
  spreads them across the row and submits on its own; an explicit button submits
  too. A rejected code KEEPS what was typed and appends the remaining budget —
  retyping five correct digits to fix one is how people burn their last attempt.
- Attempts live in a ref and are only ever incremented. Stepping back, returning,
  or switching method must not hand out a fresh budget. A rejected promise is a
  transport failure: it reports "no attempt was used" in the component's own
  words (never the host's message, which may not even end in a full stop) and
  leaves the count alone. At zero the field is replaced by a terminal panel that
  takes focus, says two-factor is not enabled, and fires onAttemptsExhausted once.
- Recovery: an ordered list of codes, one list item each, so a screen reader can
  walk them. "Download .txt" builds a Blob, clicks a detached anchor, and revokes
  the object URL when it is replaced and on unmount. "Copy all" writes to the
  clipboard and, if that rejects, selects the list instead and says so — never
  reports a failed copy as a success. Downloading counts as saving and ticks the
  acknowledgement itself; copying does not, because a clipboard is not storage.
- Finish carries aria-disabled (never the native attribute, which blurs the
  control and cannot explain itself) until the box is ticked. Pressing it anyway
  produces the reason instead of doing nothing. Once it fires, the step flips to
  "done" and the codes are unmounted — gone from the DOM for good.
- Once verified the flow is one-way: earlier steps are spent, so the stepper
  stops offering them. Cancelling from any earlier step first states the
  consequence ("two-factor will NOT be enabled, this key stops working") and asks
  again; there is no cancel once the codes are on screen.
- Async discipline: an in-flight ref blocks duplicate submits inside one tick, a
  mounted ref (set in the effect BODY, so StrictMode's remount re-arms it) guards
  every setState after an await, and callbacks live in a latest-ref so inline
  arrow functions never re-run an effect.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground, bg-primary /
  text-primary-foreground, border-input, bg-muted/50, text-muted-foreground, and
  border-destructive/30 + bg-destructive/10 + text-destructive for the
  irreversible-step warning, the failed-copy line and the lockout panel.
- Card padding is p-4 sm:p-6 and the OTP cells shrink under sm
  ([&_input]:size-9 sm:[&_input]:size-11) so six cells plus the group separator
  fit a ~295px content box without a horizontal scrollbar.
- Recovery codes sit in a two-column grid; each item is min-w-0 with
  wrap-anywhere, so a longer code format wraps instead of widening the card
  (break-words would not — it does not shrink min-content).
- Transitions carry motion-reduce:transition-none and the spinners
  motion-reduce:animate-none; nothing in the flow depends on animation.
- Accessibility: aria-current="step" on the current step, focus-visible rings on
  every control, a decorative stepper connector hidden under sm (the row wraps
  there), live regions always mounted rather than inserted along with their text,
  and the QR labelled by hand so the whole otpauth URI is not read aloud.

Customization levers
- Methods: pass one enrolment object for a three-step flow, both for four. Add a
  third (hardware key, email) by extending the union, the picker map and the
  enroll branch — the step machine itself does not change.
- Attempt policy: maxAttempts is the whole budget; make the lockout panel offer a
  support link, or call your rate limiter from onAttemptsExhausted.
- Code length: CODE_LENGTH is a module constant threaded into the OTP field, the
  copy and the messages — 8-digit TOTP is a one-line change.
- Acknowledgement: require BOTH a download and a tick by splitting the flag in
  two, or drop the gate for a low-stakes account by passing an empty code array.
- Recovery file: downloadFileName renames it; prepend an account header inside
  the Blob if your support team needs to identify the set.
- SMS pacing: add a countdown around sms.onSend if your provider bills per
  message; the in-flight guard is deliberately the only limit here.
- Leaving guard: this block is a card, so it does not touch beforeunload — add
  one on the host page while the step is "recovery" if it owns a whole route.
- Density: the QR is 148px and the card p-4 sm:p-6; scale them together, and keep
  the OTP cell override if you shrink the card further.

Concepts

  • One-time reveal — the recovery codes exist in the DOM for exactly one step. Finishing unmounts them, so "this is the only time you will see these" is enforced by the markup rather than by a sentence asking the user to be careful.
  • Acknowledge-before-close gate — the finish button is aria-disabled rather than natively disabled: the browser blurs a disabled control the instant it becomes disabled, and a disabled button has no way to say why it will not fire. Pressing it anyway produces an explanation, and either ticking the box or downloading the file opens it.
  • Monotonic attempt budget — attempts live in a ref that is only ever incremented, so walking back a step and returning cannot mint a fresh allowance. The count belongs to the enrolment session, not to the screen you happen to be on.
  • Transport failure is not a wrong code — a rejected promise means nothing was checked, so it costs no attempt and says so in the component's own sentence; only a resolved invalid spends one. Charging someone for their wifi dropping is how people lock themselves out of their own account.
  • Manual key as the real fallback — the key is drawn as four-character groups with no whitespace in the DOM, so a selection yields the unbroken string, while an sr-only twin repeats it comma-separated so it is heard in the same chunks it is seen in.
  • Degrade, never lie — a rejected clipboard write selects the value and says the copy failed. The "copied" state is forced off while the last attempt failed, so a second failure inside the two-second window cannot keep claiming success on a value that cannot be asked for again.
  • One-way after verification — the stepper offers completed steps as buttons only while the code is still unverified; afterwards there is nothing behind you left to change, and the only remaining action is to save the codes.

On This Page