TOTP Setup
block / auth
The whole turn-on-two-factor flow in one component — scan the QR or copy the key, confirm with the first code, save the recovery codes shown once — with every state owned, one native input instead of six boxes, and the secret coming only from your server.
- type
- block
- files
- 2
- registry deps
- button, input, label, checkbox, skeleton, @automindz/qr-code
- npm deps
- lucide-react
installs
- lib/automindz/totp.ts
- components/automindz/totp-setup.tsx
- Secret comes from the server — no default anywhere in the item
- Awaited onVerify: pending, wrong-code and failed-request are three states
- Recovery codes shown once; a checkbox gates Done
Pass what the server generated and two callbacks: <TotpSetup enrollment={{ secret, issuer, account }} onVerify={(code) => verify(code)} onDone={() => router.push("/settings/security")} />. **There is no default secret in lib/automindz/totp.ts, on purpose** — a constant would be the same second factor for every account — so the component shows a skeleton the size of the real layout until enrollment arrives, and a new secret resets the form because a new secret is a new enrolment. onVerify is **awaited** and returns { ok: true, recoveryCodes? } or { ok: false, reason? }: while it runs the whole <fieldset> is disabled (not just the button, so a second code cannot be typed over the one in flight), a wrong code is reported in place with the reason and the field cleared and refocused, and a *thrown* error is a different state — the code stays in the field and a Retry resends it. Return recoveryCodes only when the server generated them in the same call; when it does they are shown **once**, with Copy, a .txt download and a checkbox that gates onDone — the one confirmation this item insists on, because a user who leaves without saving them is locked out the day they lose the phone. Omit onDone and no button renders. error + onRetry is the enrolment failing to generate, said in place; unavailableReason shows the setting blocked and explained ("already enabled", "managed by your SSO provider") rather than hidden. It is **one native <input inputMode="numeric" autoComplete="one-time-code" maxLength={digits}>, not six boxes**, so paste, SMS and iOS autofill, backspace and screen readers all come from the platform — which is why there is no input-otp dependency. totpUri() builds the otpauth:// URI the QR carries: label parts are encoded separately (a colon or a space in the issuer cannot break the label), the secret is normalised (upper-case, no spaces, no =), and algorithm, digits and period are **left off unless set**, because several authenticators ignore non-defaults and a server verifying SHA256 or 8 digits against them rejects every code while the setup screen looks fine — set them only when tested end to end. The QR sits in a bg-background frame with margin={0} so it has a light ground on a dark card; the manual key beside it is real text in a <details>, grouped in fours, so a user with no camera has an equal path. The only motion is the spinner, with motion-reduce:animate-none.
Variations
one state per section
- 01Scan, confirm, save the codes
- 02Success without recovery codes
- 03Waiting for the server's secret
- 04The enrolment could not be generated
- 05Blocked, and it says why
Scan, confirm, save the codes
Type 123456 to succeed — the fake server returns eight recovery codes, shown once, with Copy, a .txt download and the checkbox that gates Done. Any other six digits is a wrong code: reported in place with the reason, the field cleared and refocused. Type 999999 and the request itself fails, which is a different state — the code stays in the field and a Retry resends it. While a code is in flight the whole fieldset is disabled, not just the button. The manual key under "Can't scan?" is the same secret the QR carries, grouped in fours. The only motion here is the spinner, and it carries motion-reduce:animate-none.
Success without recovery codes
onVerify returned { ok: true } with no recoveryCodes, so the success state is just the confirmation and Done — nothing to save, nothing to gate. Return codes only when the server generated them in the same call.
Waiting for the server's secret
enrollment is undefined while the server generates it, and the skeleton is the shape of the real layout — two step headings, the QR square, the key line, the input row — so nothing moves when the enrolment lands. There is no default secret anywhere in this item: a constant would be the same second factor for every account.
The enrolment could not be generated
error renders in place as a role="alert" with the reason, and onRetry gives it a button that stays busy while the retry runs — here it takes 1.2s and then the real form lands.
