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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/two-factor-setup.jsonPrompt
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-disabledrather 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
invalidspends 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-onlytwin 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.
Notification Preferences
An events × channels notification matrix that saves per switch, with locked always-on rows, a disconnected channel column, bulk row/column actions and midnight-spanning quiet hours.
Usage Dashboard
A period-scoped usage block — headline, quota bar, cumulative burn-up curve and per-dimension breakdown all derived from one bucket array, plus a run-rate projection stated apart from actual usage.