{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "two-factor-setup",
  "title": "Two-Factor Setup",
  "description": "The enable-2FA flow around its one irreversible step — QR plus a copyable manual key, a six-digit check whose attempt budget only ever goes down, and recovery codes rendered once behind a finish button that refuses to fire until they are saved.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.zyeon.ai/r/otp-input.json",
    "https://ui.zyeon.ai/r/qr-code.json",
    "https://ui.zyeon.ai/r/use-copy-to-clipboard.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/two-factor-setup.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowLeft,\n  Check,\n  Copy,\n  Download,\n  KeyRound,\n  LoaderCircle,\n  ShieldCheck,\n  Smartphone,\n  TriangleAlert,\n} from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useCopyToClipboard } from \"@/hooks/use-copy-to-clipboard\"\nimport { OtpInput } from \"@/components/ui/otp-input\"\nimport { QrCode } from \"@/components/ui/qr-code\"\n\n/** TOTP apps and SMS providers both send six digits; the OTP field is sized for it. */\nconst CODE_LENGTH = 6\n/** Manual keys are read four characters at a time — by eye and by screen reader. */\nconst KEY_GROUP_SIZE = 4\n\nexport type TwoFactorMethod = \"authenticator\" | \"sms\"\n\nexport type TwoFactorStep = \"method\" | \"enroll\" | \"verify\" | \"recovery\" | \"done\"\n\n/**\n * What `onVerify` resolves with. A resolved `\"invalid\"` is a wrong code and costs\n * an attempt; a *rejected* promise is a transport failure and costs nothing —\n * spending someone's last attempt because their wifi dropped is how people get\n * locked out of their own account.\n */\nexport type TwoFactorVerifyResult = { status: \"verified\" } | { status: \"invalid\"; message?: string }\n\nexport interface TwoFactorAuthenticatorEnrolment {\n  /** Base32 shared secret, generated server-side. Shown grouped, copied raw. */\n  secret: string\n  /** The `otpauth://` URI the QR encodes — built server-side, never derived here. */\n  uri: string\n}\n\nexport interface TwoFactorSmsEnrolment {\n  /** Destination shown to the user. Mask it before it leaves your server. */\n  phone: string\n  /** Sends (and later resends) the code. Reject to surface the failure. */\n  onSend: () => Promise<void>\n}\n\nexport interface TwoFactorSetupProps extends React.HTMLAttributes<HTMLElement> {\n  /**\n   * Authenticator enrolment material. Omit it and the authenticator branch is\n   * not offered at all — which is also why there is no separate `methods` prop:\n   * the offer and the data it needs cannot drift apart.\n   */\n  authenticator?: TwoFactorAuthenticatorEnrolment\n  /** SMS enrolment material. Omit it and the SMS branch is not offered. */\n  sms?: TwoFactorSmsEnrolment\n  /**\n   * One-time recovery codes, generated server-side alongside the secret. They\n   * are rendered on the last step only and are gone from the DOM the moment it\n   * is finished — this component never regenerates or re-reveals them.\n   */\n  recoveryCodes: string[]\n  /** Wrong codes allowed in one enrolment session. Clamped to at least 1. Default 5. */\n  maxAttempts?: number\n  /** Verifies one code. Resolve `\"invalid\"` for a wrong code, reject for a transport failure. */\n  onVerify: (input: { method: TwoFactorMethod; code: string }) => Promise<TwoFactorVerifyResult>\n  /** Fires once, only after the recovery codes have been acknowledged. */\n  onComplete?: () => void\n  /** Fires when the user confirms they are abandoning setup. */\n  onCancel?: () => void\n  /** Fires once when `maxAttempts` wrong codes have been entered. */\n  onAttemptsExhausted?: () => void\n  heading?: string\n  description?: string\n  /** File name for the recovery-code download. Default `recovery-codes.txt`. */\n  downloadFileName?: string\n  className?: string\n}\n\nconst STEP_LABELS: Record<Exclude<TwoFactorStep, \"done\">, string> = {\n  method: \"Method\",\n  enroll: \"Set up\",\n  verify: \"Verify\",\n  recovery: \"Save codes\",\n}\n\nconst PRIMARY_BUTTON =\n  \"inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none motion-reduce:transition-none\"\n\nconst SECONDARY_BUTTON =\n  \"inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-input bg-background px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n\nconst ICON_BUTTON =\n  \"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-input bg-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none [&_svg]:size-4\"\n\n/** A rejection that is not an Error still has to become a sentence a human can read. */\nfunction rejectionText(error: unknown, fallback: string): string {\n  if (error instanceof Error && error.message) return error.message\n  if (typeof error === \"string\" && error) return error\n  return fallback\n}\n\n/** \"JBSWY3DPEHPK3PXP\" → [\"JBSW\", \"Y3DP\", \"EHPK\", \"3PXP\"]. */\nfunction groupSecret(secret: string): string[] {\n  const cleaned = secret.replace(/\\s+/g, \"\").toUpperCase()\n  const groups: string[] = []\n  for (let i = 0; i < cleaned.length; i += KEY_GROUP_SIZE) {\n    groups.push(cleaned.slice(i, i + KEY_GROUP_SIZE))\n  }\n  return groups\n}\n\n/**\n * Clipboard fallback. A rejected write (insecure context, denied permission,\n * Safari's user-gesture rules) must not end in \"nothing happened\" — select the\n * value so Ctrl/Cmd+C still works.\n */\nfunction selectNodeContents(node: HTMLElement | null) {\n  if (!node || typeof window === \"undefined\") return\n  const selection = window.getSelection()\n  if (!selection) return\n  const range = document.createRange()\n  range.selectNodeContents(node)\n  selection.removeAllRanges()\n  selection.addRange(range)\n}\n\n/**\n * Copy + manual-selection fallback for one value. `copied` is forced false while\n * the last attempt failed: the hook's flag stays true for two seconds, so a\n * second attempt failing inside that window would otherwise keep reading\n * \"Copied\" — on a value the user cannot ask for again.\n */\nfunction useCopyWithFallback() {\n  const { copied, copy } = useCopyToClipboard()\n  const [failed, setFailed] = React.useState(false)\n\n  /** `fallbackNode` is read at call time by the handler, never during render. */\n  const run = async (text: string, fallbackNode: HTMLElement | null) => {\n    const ok = await copy(text)\n    setFailed(!ok)\n    if (!ok) selectNodeContents(fallbackNode)\n  }\n\n  return { copied: copied && !failed, failed, run }\n}\n\n/**\n * The enable-two-factor flow, end to end: pick a method → enrol (QR + a manual\n * key, or an SMS) → type the six digits → **save the one-time recovery codes** →\n * done.\n *\n * The whole component is organised around the fact that step four is\n * irreversible. Recovery codes are rendered exactly once, the finish button\n * refuses to fire until they are acknowledged, and finishing removes them from\n * the DOM for good. Everything else — attempts that only ever go down, a\n * transport failure that costs no attempt, a manual key that survives a dead\n * Clipboard API — exists so that the user reaches that step with something they\n * can actually keep.\n *\n * It generates nothing: the secret, the `otpauth://` URI and the codes are all\n * server-side facts handed in as props.\n */\nexport const TwoFactorSetup = React.forwardRef<HTMLElement, TwoFactorSetupProps>(\n  (\n    {\n      authenticator,\n      sms,\n      recoveryCodes,\n      maxAttempts = 5,\n      onVerify,\n      onComplete,\n      onCancel,\n      onAttemptsExhausted,\n      heading = \"Turn on two-factor authentication\",\n      description = \"A second factor means a stolen password is not enough to sign in as you.\",\n      downloadFileName = \"recovery-codes.txt\",\n      className,\n      ...rest\n    },\n    forwardedRef,\n  ) => {\n    const baseId = React.useId()\n    const headingId = `${baseId}-heading`\n    const ackId = `${baseId}-ack`\n\n    const offered = React.useMemo(() => {\n      const list: TwoFactorMethod[] = []\n      if (authenticator) list.push(\"authenticator\")\n      if (sms) list.push(\"sms\")\n      return list\n    }, [authenticator, sms])\n    const hasPicker = offered.length > 1\n\n    const attemptLimit = Math.max(1, Math.floor(maxAttempts) || 1)\n\n    const [method, setMethod] = React.useState<TwoFactorMethod>(\"authenticator\")\n    const [rawStep, setRawStep] = React.useState<TwoFactorStep>(hasPicker ? \"method\" : \"enroll\")\n    const [code, setCode] = React.useState(\"\")\n    const [attemptsUsed, setAttemptsUsed] = React.useState(0)\n    const [status, setStatus] = React.useState<\"idle\" | \"pending\" | \"invalid\" | \"error\">(\"idle\")\n    const [message, setMessage] = React.useState<string | null>(null)\n    const [sendState, setSendState] = React.useState<\"idle\" | \"pending\" | \"sent\" | \"error\">(\"idle\")\n    const [sendMessage, setSendMessage] = React.useState<string | null>(null)\n    const [acknowledged, setAcknowledged] = React.useState(false)\n    const [nudge, setNudge] = React.useState(false)\n    const [confirmCancel, setConfirmCancel] = React.useState(false)\n\n    // Derived, not stored: a props change that removes a method must not strand\n    // the flow on a picker with one option or on a branch that no longer exists.\n    const activeMethod = offered.includes(method) ? method : (offered[0] ?? \"authenticator\")\n    const step = rawStep === \"method\" && !hasPicker ? \"enroll\" : rawStep\n\n    const secretNodeRef = React.useRef<HTMLElement>(null)\n    const codesNodeRef = React.useRef<HTMLOListElement>(null)\n    // Destructured on the spot: the returned `run` closes over the clipboard\n    // hook's timer ref, so keeping the object whole would make every read of it\n    // a ref access during render.\n    const { copied: secretCopied, failed: secretCopyFailed, run: runSecretCopy } = useCopyWithFallback()\n    const { copied: codesCopied, failed: codesCopyFailed, run: runCodesCopy } = useCopyWithFallback()\n\n    const mountedRef = React.useRef(false)\n    // Flipped synchronously at the top of the handler: several presses inside one\n    // tick would all read the same stale `status`.\n    const pendingRef = React.useRef(false)\n    const sendingRef = React.useRef(false)\n    // The source of truth for attempts. It is only ever incremented — going back\n    // a step or switching method must not hand out a fresh budget.\n    const attemptsRef = React.useRef(0)\n    const exhaustedRef = React.useRef(false)\n    const completedRef = React.useRef(false)\n    const objectUrlRef = React.useRef<string | null>(null)\n    const ackRef = React.useRef<HTMLInputElement>(null)\n    const lockedRef = React.useRef<HTMLDivElement>(null)\n\n    // Latest-ref: consumers pass inline arrow functions, so these must never sit\n    // in a dependency array.\n    const callbacksRef = React.useRef({ onVerify, onComplete, onCancel, onAttemptsExhausted, sms })\n    React.useEffect(() => {\n      callbacksRef.current = { onVerify, onComplete, onCancel, onAttemptsExhausted, sms }\n    })\n\n    // Where the user actually is when an async send lands — a step captured in the\n    // handler's closure is the step they were on when they pressed the button.\n    const stepRef = React.useRef(rawStep)\n    React.useEffect(() => {\n      stepRef.current = rawStep\n    })\n\n    React.useEffect(() => {\n      // Set in the effect BODY, not only cleared in cleanup: StrictMode runs\n      // mount → cleanup → mount in dev, so a flag that is merely cleared would\n      // read false for the live instance and no result would ever land.\n      mountedRef.current = true\n      return () => {\n        mountedRef.current = false\n      }\n    }, [])\n\n    // The download URL is revoked when it is replaced and when the component\n    // goes away; a blob that outlives its component is a leak with the secret\n    // still inside it.\n    React.useEffect(\n      () => () => {\n        if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)\n      },\n      [],\n    )\n\n    const attemptsLeft = Math.max(0, attemptLimit - attemptsUsed)\n    const locked = attemptsLeft === 0\n\n    // The OTP field unmounts when the budget runs out, which would drop focus on\n    // <body>. The panel that replaces it takes the focus instead.\n    React.useEffect(() => {\n      if (locked) lockedRef.current?.focus()\n    }, [locked])\n\n    const stepOrder = React.useMemo<TwoFactorStep[]>(\n      () => (hasPicker ? [\"method\", \"enroll\", \"verify\", \"recovery\"] : [\"enroll\", \"verify\", \"recovery\"]),\n      [hasPicker],\n    )\n    const stepIndex = step === \"done\" ? stepOrder.length : stepOrder.indexOf(step)\n    // Once the code has been verified the flow is one-way: the earlier steps are\n    // spent, and there is nothing behind them left to change. A used-up attempt\n    // budget is terminal for the same reason — stepping back would only lead\n    // here again.\n    const canNavigateBack = !locked && (step === \"method\" || step === \"enroll\" || step === \"verify\")\n\n    const goTo = (next: TwoFactorStep) => {\n      setRawStep(next)\n      setStatus(\"idle\")\n      setMessage(null)\n      setConfirmCancel(false)\n    }\n\n    const chooseMethod = (next: TwoFactorMethod) => {\n      setMethod(next)\n      // A code minted for one channel is meaningless on the other — but the\n      // attempt budget is deliberately untouched.\n      setCode(\"\")\n      setStatus(\"idle\")\n      setMessage(null)\n      setSendState(\"idle\")\n      setSendMessage(null)\n    }\n\n    const handleSend = async () => {\n      const enrolment = callbacksRef.current.sms\n      if (!enrolment || sendingRef.current) return\n      sendingRef.current = true\n      setSendState(\"pending\")\n      setSendMessage(null)\n      try {\n        await enrolment.onSend()\n      } catch (error) {\n        sendingRef.current = false\n        if (!mountedRef.current) return\n        setSendState(\"error\")\n        setSendMessage(rejectionText(error, \"We couldn't send the code. Try again.\"))\n        return\n      }\n      sendingRef.current = false\n      if (!mountedRef.current) return\n      setSendState(\"sent\")\n      setSendMessage(null)\n      // Advance only the user who is still standing on the step the send was\n      // started from: pressing Back while the SMS is in flight must not fling\n      // them off the picker and onto the code field when it finally lands.\n      if (rawStep === \"enroll\" && stepRef.current === \"enroll\") goTo(\"verify\")\n    }\n\n    const runVerify = async (value: string) => {\n      if (pendingRef.current || locked) return\n      if (value.length !== CODE_LENGTH) {\n        setStatus(\"invalid\")\n        setMessage(`Enter all ${CODE_LENGTH} digits.`)\n        return\n      }\n\n      pendingRef.current = true\n      setStatus(\"pending\")\n      setMessage(null)\n\n      let result: TwoFactorVerifyResult\n      try {\n        result = await callbacksRef.current.onVerify({ method: activeMethod, code: value })\n      } catch (error) {\n        pendingRef.current = false\n        // Re-check liveness after the await: a late rejection landing on an\n        // unmounted instance would setState on a dead component.\n        if (!mountedRef.current) return\n        // Deliberately no attempt spent — this was the network, not the user.\n        setStatus(\"error\")\n        setMessage(rejectionText(error, \"We couldn't reach the server.\"))\n        return\n      }\n      pendingRef.current = false\n      if (!mountedRef.current) return\n\n      if (result.status === \"verified\") {\n        setStatus(\"idle\")\n        setMessage(null)\n        setRawStep(\"recovery\")\n        return\n      }\n\n      attemptsRef.current += 1\n      const used = attemptsRef.current\n      setAttemptsUsed(used)\n      setStatus(\"invalid\")\n      // The code stays in the field: retyping five digits to fix one is how\n      // people burn their last attempt.\n      setMessage(result.message ?? \"That code didn't match.\")\n      if (used >= attemptLimit && !exhaustedRef.current) {\n        exhaustedRef.current = true\n        callbacksRef.current.onAttemptsExhausted?.()\n      }\n    }\n\n    const handleDownload = () => {\n      if (recoveryCodes.length === 0 || typeof window === \"undefined\") return\n      if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)\n      const blob = new Blob([`${recoveryCodes.join(\"\\n\")}\\n`], { type: \"text/plain;charset=utf-8\" })\n      const url = URL.createObjectURL(blob)\n      objectUrlRef.current = url\n      const anchor = document.createElement(\"a\")\n      anchor.href = url\n      anchor.download = downloadFileName\n      anchor.rel = \"noopener\"\n      anchor.click()\n      // Downloading *is* saving them, so it satisfies the gate on its own.\n      setAcknowledged(true)\n      setNudge(false)\n    }\n\n    const needsAcknowledgement = recoveryCodes.length > 0\n    const canFinish = !needsAcknowledgement || acknowledged\n\n    const handleFinish = () => {\n      if (!canFinish) {\n        setNudge(true)\n        ackRef.current?.focus()\n        return\n      }\n      if (completedRef.current) return\n      completedRef.current = true\n      setRawStep(\"done\")\n      callbacksRef.current.onComplete?.()\n    }\n\n    const handleCancel = () => {\n      setConfirmCancel(false)\n      callbacksRef.current.onCancel?.()\n    }\n\n    const secretGroups = authenticator ? groupSecret(authenticator.secret) : []\n\n    /* ------------------------------------------------------------------ pieces */\n\n    const stepper = (\n      <ol aria-label=\"Setup steps\" className=\"flex flex-wrap items-center gap-x-2 gap-y-1.5\">\n        {stepOrder.map((entry, index) => {\n          const done = index < stepIndex\n          const current = index === stepIndex\n          const reachable = done && canNavigateBack\n          const label = STEP_LABELS[entry as Exclude<TwoFactorStep, \"done\">]\n          const body = (\n            <>\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"inline-flex size-5 shrink-0 items-center justify-center rounded-full border text-[0.625rem] font-semibold tabular-nums\",\n                  current && \"border-primary bg-primary text-primary-foreground\",\n                  done && \"border-primary/40 bg-primary/10 text-foreground\",\n                  !current && !done && \"border-input text-muted-foreground\",\n                )}\n              >\n                {done ? <Check className=\"size-3\" /> : index + 1}\n              </span>\n              {label}\n            </>\n          )\n          return (\n            <li className=\"flex items-center gap-2\" key={entry}>\n              {/* Decorative only, and hidden on narrow screens: the row wraps\n                  there, which would strand a connector at the start of a line. */}\n              {index > 0 ? <span aria-hidden=\"true\" className=\"hidden h-px w-5 bg-border sm:block\" /> : null}\n              {reachable ? (\n                <button\n                  className=\"inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n                  onClick={() => goTo(entry)}\n                  type=\"button\"\n                >\n                  {body}\n                </button>\n              ) : (\n                <span\n                  aria-current={current ? \"step\" : undefined}\n                  className={cn(\n                    \"inline-flex items-center gap-1.5 px-1 py-0.5 text-xs font-medium\",\n                    current ? \"text-foreground\" : \"text-muted-foreground\",\n                  )}\n                >\n                  {body}\n                </span>\n              )}\n            </li>\n          )\n        })}\n      </ol>\n    )\n\n    const cancelRow =\n      step === \"method\" || step === \"enroll\" || step === \"verify\" ? (\n        confirmCancel ? (\n          <div\n            className=\"flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-3\"\n            role=\"alert\"\n          >\n            <p className=\"text-xs text-destructive\">\n              Two-factor authentication will <strong className=\"font-semibold\">not</strong> be enabled\n              and this setup key stops working. Your account keeps its password only; you can start\n              again from your security settings.\n            </p>\n            <div className=\"flex flex-wrap gap-2\">\n              <button\n                className={cn(SECONDARY_BUTTON, \"h-8 px-3 text-xs\")}\n                onClick={handleCancel}\n                type=\"button\"\n              >\n                Leave without enabling\n              </button>\n              <button\n                className={cn(SECONDARY_BUTTON, \"h-8 px-3 text-xs\")}\n                onClick={() => setConfirmCancel(false)}\n                type=\"button\"\n              >\n                Keep setting up\n              </button>\n            </div>\n          </div>\n        ) : (\n          <button\n            className=\"cursor-pointer self-start text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n            onClick={() => setConfirmCancel(true)}\n            type=\"button\"\n          >\n            Cancel setup\n          </button>\n        )\n      ) : null\n\n    const methodStep = (\n      <fieldset className=\"flex flex-col gap-3\">\n        <legend className=\"text-sm font-medium\">How do you want to get codes?</legend>\n        <div className=\"flex flex-col gap-2\">\n          {offered.map(option => {\n            const selected = activeMethod === option\n            return (\n              <label\n                className={cn(\n                  \"flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors motion-reduce:transition-none\",\n                  \"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring\",\n                  selected ? \"border-primary bg-primary/5\" : \"border-input hover:border-ring\",\n                )}\n                key={option}\n              >\n                <input\n                  checked={selected}\n                  className=\"mt-1 size-4 shrink-0 cursor-pointer accent-primary outline-none\"\n                  name={`${baseId}-method`}\n                  onChange={() => chooseMethod(option)}\n                  type=\"radio\"\n                  value={option}\n                />\n                <span className=\"flex min-w-0 flex-col gap-0.5\">\n                  <span className=\"flex items-center gap-2 text-sm font-medium\">\n                    {option === \"authenticator\" ? (\n                      <KeyRound aria-hidden=\"true\" className=\"size-4\" />\n                    ) : (\n                      <Smartphone aria-hidden=\"true\" className=\"size-4\" />\n                    )}\n                    {option === \"authenticator\" ? \"Authenticator app\" : \"Text message\"}\n                  </span>\n                  <span className=\"text-xs text-muted-foreground\">\n                    {option === \"authenticator\"\n                      ? \"Codes are generated on your device and keep working without signal.\"\n                      : `A code is texted to ${sms?.phone ?? \"your phone\"} each time you sign in.`}\n                  </span>\n                </span>\n              </label>\n            )\n          })}\n        </div>\n      </fieldset>\n    )\n\n    const authenticatorEnrol = authenticator ? (\n      <div className=\"flex flex-col gap-4\">\n        <div className=\"flex flex-col items-center gap-4 sm:flex-row sm:items-start sm:gap-6\">\n          <QrCode\n            aria-label=\"QR code containing your two-factor setup key\"\n            className=\"shrink-0\"\n            size={148}\n            value={authenticator.uri}\n          />\n          <ol className=\"flex min-w-0 flex-1 list-inside list-decimal flex-col gap-1 text-sm text-muted-foreground\">\n            <li>Open your authenticator app.</li>\n            <li>Scan this code, or enter the key below by hand.</li>\n            <li>The app starts showing a {CODE_LENGTH}-digit code that changes every 30 seconds.</li>\n          </ol>\n        </div>\n\n        <div className=\"flex flex-col gap-1.5\">\n          <p className=\"text-xs text-muted-foreground\">Can&apos;t scan it? Type this key instead.</p>\n          <div className=\"flex items-center gap-2 rounded-lg border bg-muted/50 p-2\">\n            {/* The groups are separate spans with no whitespace between them, so\n                selecting the element yields the raw key rather than a spaced\n                copy an authenticator app might reject. aria-hidden because the\n                sibling below is the version worth listening to. */}\n            <code\n              aria-hidden=\"true\"\n              className=\"flex min-w-0 flex-1 flex-wrap gap-x-2 font-mono text-sm tracking-wide select-all\"\n              ref={secretNodeRef}\n            >\n              {secretGroups.map((group, index) => (\n                <span key={index}>{group}</span>\n              ))}\n            </code>\n            {/* Read as four-character chunks: the comma gives the screen reader a\n                pause where the eye gets a gap. */}\n            <span className=\"sr-only\">Setup key: {secretGroups.join(\", \")}</span>\n            <button\n              aria-label=\"Copy the setup key\"\n              className={ICON_BUTTON}\n              onClick={() => void runSecretCopy(authenticator.secret, secretNodeRef.current)}\n              type=\"button\"\n            >\n              {secretCopied ? <Check aria-hidden=\"true\" /> : <Copy aria-hidden=\"true\" />}\n            </button>\n          </div>\n          <p aria-live=\"polite\" className=\"min-h-4 text-xs\">\n            {secretCopyFailed ? (\n              <span className=\"text-destructive\">\n                Copy failed — the key is selected, press Ctrl/Cmd+C to copy it manually.\n              </span>\n            ) : secretCopied ? (\n              <span className=\"text-muted-foreground\">Setup key copied.</span>\n            ) : null}\n          </p>\n        </div>\n      </div>\n    ) : null\n\n    const smsEnrol = sms ? (\n      <div className=\"flex flex-col gap-3\">\n        <p className=\"text-sm text-muted-foreground\">\n          We&apos;ll text a {CODE_LENGTH}-digit code to{\" \"}\n          <strong className=\"font-medium text-foreground\">{sms.phone}</strong>. It expires in a few\n          minutes.\n        </p>\n        <button\n          aria-busy={sendState === \"pending\" || undefined}\n          aria-disabled={sendState === \"pending\" || undefined}\n          className={cn(PRIMARY_BUTTON, \"self-start\", sendState === \"pending\" && \"cursor-progress opacity-80\")}\n          onClick={() => void handleSend()}\n          type=\"button\"\n        >\n          {sendState === \"pending\" ? (\n            <LoaderCircle aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />\n          ) : null}\n          {sendState === \"pending\" ? \"Sending…\" : \"Send the code\"}\n        </button>\n        <p aria-live=\"polite\" className=\"min-h-4 text-xs\">\n          {sendMessage ? <span className=\"text-destructive\">{sendMessage}</span> : null}\n        </p>\n      </div>\n    ) : null\n\n    const verifyBody = locked ? (\n      <div\n        className=\"flex flex-col gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n        ref={lockedRef}\n        role=\"alert\"\n        tabIndex={-1}\n      >\n        <p className=\"flex items-center gap-2 text-sm font-medium text-destructive\">\n          <TriangleAlert aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n          Too many incorrect codes\n        </p>\n        <p className=\"text-xs text-destructive\">\n          All {attemptLimit} {attemptLimit === 1 ? \"attempt\" : \"attempts\"} for this setup{\" \"}\n          {attemptLimit === 1 ? \"was\" : \"were\"} used, so it has been stopped. Two-factor\n          authentication is <strong className=\"font-semibold\">not</strong> enabled. Start again from\n          your security settings to get a fresh key.\n        </p>\n      </div>\n    ) : (\n      <div className=\"flex flex-col gap-3\">\n        <p className=\"text-sm text-muted-foreground\">\n          {activeMethod === \"authenticator\"\n            ? `Enter the ${CODE_LENGTH}-digit code your authenticator app is showing now.`\n            : `Enter the ${CODE_LENGTH}-digit code we texted to ${sms?.phone ?? \"your phone\"}.`}\n        </p>\n        <OtpInput\n          // The cells shrink below sm so six of them plus the group separator fit\n          // a 320px-wide card without a horizontal scrollbar.\n          className=\"[&_input]:size-9 sm:[&_input]:size-11\"\n          groupSize={3}\n          invalid={status === \"invalid\"}\n          label=\"Verification code\"\n          length={CODE_LENGTH}\n          onChange={next => {\n            setCode(next)\n            if (status === \"invalid\" || status === \"error\") {\n              setStatus(\"idle\")\n              setMessage(null)\n            }\n          }}\n          onComplete={value => void runVerify(value)}\n          value={code}\n        />\n        {/* Always mounted: a live region inserted at the same moment as its text\n            is unreliable in most screen readers. */}\n        <p aria-live=\"polite\" className=\"min-h-8 text-xs\" role=\"status\">\n          {message ? (\n            <span className={status === \"invalid\" || status === \"error\" ? \"text-destructive\" : \"\"}>\n              {message}\n              {status === \"invalid\" && attemptsUsed > 0\n                ? ` ${attemptsLeft} ${attemptsLeft === 1 ? \"attempt\" : \"attempts\"} left.`\n                : \"\"}\n              {/* Our sentence, never the host's: a transport failure spends no\n                  attempt, and saying so must not depend on whatever message the\n                  rejection happened to carry (or whether it ends in a full stop). */}\n              {status === \"error\" ? <span className=\"block\">No attempt was used.</span> : null}\n            </span>\n          ) : sendState === \"sent\" ? (\n            <span className=\"text-muted-foreground\">Code sent to {sms?.phone}.</span>\n          ) : null}\n        </p>\n        {sms && activeMethod === \"sms\" ? (\n          <button\n            aria-disabled={sendState === \"pending\" || undefined}\n            className=\"cursor-pointer self-start text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none\"\n            onClick={() => void handleSend()}\n            type=\"button\"\n          >\n            {sendState === \"pending\" ? \"Sending…\" : \"Send a new code\"}\n          </button>\n        ) : null}\n      </div>\n    )\n\n    const recoveryStep = (\n      <div className=\"flex flex-col gap-4\">\n        {recoveryCodes.length > 0 ? (\n          <>\n            <div className=\"flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3\">\n              <TriangleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0 text-destructive\" />\n              <p className=\"text-xs text-destructive\">\n                This is the only time these codes are shown. Save them now — once you finish, they\n                cannot be displayed again, only replaced with a new set.\n              </p>\n            </div>\n\n            <ol\n              aria-label=\"Recovery codes\"\n              className=\"grid grid-cols-2 gap-x-3 gap-y-1.5 rounded-lg border bg-muted/50 p-3\"\n              ref={codesNodeRef}\n            >\n              {recoveryCodes.map((entry, index) => (\n                // Index keys on purpose: the list is fixed for the lifetime of this\n                // step, and two identical codes would otherwise collide.\n                <li\n                  className=\"flex min-w-0 items-baseline gap-2 font-mono text-xs wrap-anywhere sm:text-sm\"\n                  key={index}\n                >\n                  <span aria-hidden=\"true\" className=\"w-4 shrink-0 text-end text-muted-foreground tabular-nums\">\n                    {index + 1}\n                  </span>\n                  <span className=\"min-w-0\">{entry}</span>\n                </li>\n              ))}\n            </ol>\n\n            <div className=\"flex flex-wrap items-center gap-2\">\n              <button className={cn(SECONDARY_BUTTON, \"h-9 px-3 text-xs\")} onClick={handleDownload} type=\"button\">\n                <Download aria-hidden=\"true\" className=\"size-4\" />\n                Download .txt\n              </button>\n              <button\n                className={cn(SECONDARY_BUTTON, \"h-9 px-3 text-xs\")}\n                onClick={() => void runCodesCopy(recoveryCodes.join(\"\\n\"), codesNodeRef.current)}\n                type=\"button\"\n              >\n                {codesCopied ? (\n                  <Check aria-hidden=\"true\" className=\"size-4\" />\n                ) : (\n                  <Copy aria-hidden=\"true\" className=\"size-4\" />\n                )}\n                Copy all\n              </button>\n              <p aria-live=\"polite\" className=\"min-h-4 text-xs\">\n                {codesCopyFailed ? (\n                  <span className=\"text-destructive\">\n                    Copy failed — the codes are selected, press Ctrl/Cmd+C.\n                  </span>\n                ) : codesCopied ? (\n                  <span className=\"text-muted-foreground\">\n                    Copied. A clipboard is not storage — save them somewhere lasting.\n                  </span>\n                ) : null}\n              </p>\n            </div>\n\n            <div className=\"flex flex-col gap-1.5\">\n              <label className=\"flex cursor-pointer items-start gap-2 text-sm\" htmlFor={ackId}>\n                <input\n                  checked={acknowledged}\n                  className=\"mt-0.5 size-4 shrink-0 cursor-pointer accent-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                  id={ackId}\n                  onChange={event => {\n                    setAcknowledged(event.target.checked)\n                    if (event.target.checked) setNudge(false)\n                  }}\n                  ref={ackRef}\n                  type=\"checkbox\"\n                />\n                I have saved these recovery codes somewhere safe\n              </label>\n              {nudge ? (\n                <p className=\"text-xs text-destructive\" role=\"alert\">\n                  Save the codes first — they cannot be shown again.\n                </p>\n              ) : null}\n            </div>\n          </>\n        ) : (\n          <p className=\"text-sm text-muted-foreground\">\n            No recovery codes were issued for this account. Ask an administrator how to regain access\n            if you lose your second factor.\n          </p>\n        )}\n      </div>\n    )\n\n    const doneStep = (\n      <div className=\"flex flex-col items-start gap-3 rounded-xl border bg-muted/40 p-4\" role=\"status\">\n        <span className=\"inline-flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n          <ShieldCheck aria-hidden=\"true\" className=\"size-5\" />\n        </span>\n        <div className=\"flex flex-col gap-1\">\n          <p className=\"text-sm font-medium\">Two-factor authentication is on</p>\n          <p className=\"text-sm text-muted-foreground\">\n            {activeMethod === \"authenticator\"\n              ? \"You'll be asked for a code from your authenticator app the next time you sign in.\"\n              : `You'll be texted a code at ${sms?.phone ?? \"your phone\"} the next time you sign in.`}{\" \"}\n            {recoveryCodes.length > 0\n              ? \"Your recovery codes are no longer shown here — generate a new set from your security settings if you lose them.\"\n              : \"\"}\n          </p>\n        </div>\n      </div>\n    )\n\n    /* ------------------------------------------------------------------ shell */\n\n    const showBack = (step === \"enroll\" && hasPicker) || (step === \"verify\" && !locked)\n\n    return (\n      <section\n        {...rest}\n        aria-labelledby={headingId}\n        className={cn(\n          \"w-full rounded-2xl border bg-card p-4 text-card-foreground sm:p-6\",\n          className,\n        )}\n        data-step={step}\n        ref={forwardedRef}\n      >\n        <div className=\"mx-auto flex w-full max-w-2xl flex-col gap-5\">\n          <div className=\"flex flex-col gap-1.5\">\n            <h2 className=\"text-lg font-semibold tracking-tight\" id={headingId}>\n              {heading}\n            </h2>\n            {description ? <p className=\"text-sm text-muted-foreground\">{description}</p> : null}\n          </div>\n\n          {stepper}\n\n          {step === \"method\" ? methodStep : null}\n          {step === \"enroll\" ? (activeMethod === \"authenticator\" ? authenticatorEnrol : smsEnrol) : null}\n          {step === \"verify\" ? verifyBody : null}\n          {step === \"recovery\" ? recoveryStep : null}\n          {step === \"done\" ? doneStep : null}\n\n          {step !== \"done\" ? (\n            <div className=\"flex flex-wrap items-center gap-2\">\n              {showBack ? (\n                <button\n                  className={SECONDARY_BUTTON}\n                  onClick={() => goTo(step === \"verify\" ? \"enroll\" : \"method\")}\n                  type=\"button\"\n                >\n                  <ArrowLeft aria-hidden=\"true\" className=\"size-4\" />\n                  Back\n                </button>\n              ) : null}\n\n              {step === \"method\" ? (\n                <button className={PRIMARY_BUTTON} onClick={() => goTo(\"enroll\")} type=\"button\">\n                  Continue\n                </button>\n              ) : null}\n\n              {step === \"enroll\" && activeMethod === \"authenticator\" ? (\n                <button className={PRIMARY_BUTTON} onClick={() => goTo(\"verify\")} type=\"button\">\n                  I&apos;ve added it\n                </button>\n              ) : null}\n\n              {step === \"verify\" && !locked ? (\n                <button\n                  aria-busy={status === \"pending\" || undefined}\n                  aria-disabled={status === \"pending\" || undefined}\n                  className={cn(PRIMARY_BUTTON, status === \"pending\" && \"cursor-progress opacity-80\")}\n                  onClick={() => void runVerify(code)}\n                  type=\"button\"\n                >\n                  {status === \"pending\" ? (\n                    <LoaderCircle\n                      aria-hidden=\"true\"\n                      className=\"size-4 animate-spin motion-reduce:animate-none\"\n                    />\n                  ) : null}\n                  {status === \"pending\" ? \"Verifying…\" : \"Verify and enable\"}\n                </button>\n              ) : null}\n\n              {step === \"recovery\" ? (\n                <button\n                  // aria-disabled, never the native attribute: the browser blurs a\n                  // control the instant it is disabled, and a disabled button gives\n                  // no way to explain *why* it will not fire.\n                  aria-disabled={!canFinish || undefined}\n                  className={cn(PRIMARY_BUTTON, !canFinish && \"opacity-60\")}\n                  onClick={handleFinish}\n                  type=\"button\"\n                >\n                  Finish\n                </button>\n              ) : null}\n            </div>\n          ) : null}\n\n          {cancelRow}\n        </div>\n      </section>\n    )\n  },\n)\n\nTwoFactorSetup.displayName = \"TwoFactorSetup\"\n\nexport default TwoFactorSetup\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}
