{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "recovery-codes",
  "title": "Recovery Codes",
  "description": "The sheet of two-factor backup codes, with the three ways off the screen that people actually use: copy, download as a .txt, and print. Reach for it wherever an account hands someone a set of one-time codes to keep — finishing two-factor or MFA enrolment after scanning the authenticator QR, the \"View recovery codes\" panel in a security settings page, regenerating a set after a lost phone, passkey and WebAuthn fallback codes, seed or backup phrases handed over once, and the onboarding step that will not let you continue until you confirm you have saved them. Common asks it answers: \"recovery codes component\", \"backup codes UI\", \"2FA recovery codes react\", \"MFA backup codes screen\", \"one-time codes list\", \"download recovery codes txt\", \"print backup codes\", \"copy recovery codes\", \"GitHub-style recovery codes\", \"show recovery codes once\", \"regenerate backup codes UI\", \"shadcn recovery codes\", \"strike through used backup code\". Official shadcn/ui has nothing for it — no recovery, backup-code, download or print item anywhere in its sixty-odd components — so an agent asked for this screen writes it inline, and the inline version is where the whole thing quietly stops working. Print is the worst of it. Everyone writes onClick={window.print()}, which prints the page rather than the codes: the nav, the sidebar and the rest of the settings form come along, and because a freshly issued set is nearly always shown inside a scrolling dialog, the printed sheet is clipped to whatever part of that dialog happened to be scrolled into view. Half the codes are missing, on paper that looks finished, and nobody finds out until the day they need them. This prints a document of its own instead — a titled, dated sheet with the codes laid out so none of them straddle a page break — through a hidden iframe that is 0x0 rather than display:none (a frame that is not displayed prints a blank page), whose srcdoc is set before insertion so the only load event is the sheet's rather than the initial about:blank, and which is torn down on afterprint rather than on the next line, because print() blocks in Chrome and Firefox but returns immediately in Safari, where removing the frame would cancel a dialog still open. The sheet is stated in black on white on purpose: browsers drop background colours when printing but keep text colours, so a dark-mode card sent to a printer comes out as pale grey on white and is close to unreadable. The download half has its own two: the anchor is put into the document before it is clicked, because Firefox ignores a click on an element outside the tree, and the object URL is revoked afterwards — an un-revoked one keeps its blob, which is to say the recovery codes, alive and addressable for the life of the document — but revoked on a later task, since releasing it in the click's own task cancels the download. The file is written with CRLF endings so Windows editors do not render it as a single line; the clipboard gets plain LF and the bare codes with no heading, because it is being pasted into a password manager's notes field. Spent codes are struck through and, because a line through text is a paint decision that reaches nobody using a screen reader, also labelled in words — and they are left out of every export, since a saved file padded with dead codes is the right length and so is worse than no file at all; the header says how many are left whenever any have been spent. role=\"list\" is put back by hand because Safari drops list semantics from a list-style-none <ul>, which would take away the one number that matters here. Pass codes as plain strings for a fresh set or as {code, used} for a set being reviewed later; onExport fires on copy, download or print, which is the signal to unlock your \"I have saved these\" button. normalizeCodes, formatCodesText, buildPrintDocument, downloadTextFile and printDocument are exported for reuse. Nothing renders a date, so it server-renders without a hydration mismatch — the timestamp is taken when a button is pressed. Composes pulld copy-button; every colour is a shadcn token, so it follows light and dark.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://pulld.pages.dev/r/copy-button.json"
  ],
  "files": [
    {
      "path": "registry/ui/recovery-codes.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Download, Printer } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { CopyButton } from \"@/registry/ui/copy-button\"\n\n/** One recovery code, and whether it has already been spent. */\nexport interface RecoveryCode {\n  /** The code itself, exactly as the server issued it. */\n  code: string\n  /** Already redeemed. Shown struck through, and left out of every export. */\n  used?: boolean\n}\n\n/**\n * What `codes` accepts. A freshly issued set is a list of strings; a set being reviewed later\n * carries the used flag, so both spellings are allowed rather than forcing the common case to wrap\n * every string in an object.\n */\nexport type RecoveryCodeInput = string | RecoveryCode\n\n/** Which controls the toolbar offers. */\nexport type RecoveryCodeAction = \"copy\" | \"download\" | \"print\"\n\nconst ACTION_ORDER: RecoveryCodeAction[] = [\"copy\", \"download\", \"print\"]\n\n/**\n * Puts the two accepted shapes into one, trimming as it goes.\n *\n * Trimming matters more here than it looks: these strings usually arrive from a JSON payload and\n * end up being typed back in by hand, and a trailing newline picked up somewhere in the middle\n * would be copied to the clipboard and printed onto paper without ever being visible on screen.\n * An entry that is empty once trimmed is dropped — there is nothing a person could type.\n */\nexport function normalizeCodes(codes: readonly RecoveryCodeInput[]): RecoveryCode[] {\n  return codes\n    .map((entry) =>\n      typeof entry === \"string\"\n        ? { code: entry.trim(), used: false }\n        : { code: entry.code.trim(), used: entry.used === true }\n    )\n    .filter((entry) => entry.code.length > 0)\n}\n\n/** The local calendar date as `YYYY-MM-DD`. */\nexport function isoDate(date: Date): string {\n  const pad = (value: number) => String(value).padStart(2, \"0\")\n  // Deliberately not `toISOString().slice(0, 10)`: that converts to UTC first, so anyone west of\n  // Greenwich printing a sheet in the evening gets tomorrow's date on it.\n  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`\n}\n\n/** What every export is built from. */\nexport interface RecoveryCodeSheet {\n  /** The unused codes, in display order. */\n  codes: readonly string[]\n  /** Heading for the sheet — the app or account these belong to. */\n  title: string\n  /** Stamped under the heading so a sheet found in a drawer can be dated. */\n  generatedAt?: Date\n  /** The line of guidance printed under the codes. */\n  note?: string | null\n}\n\n/**\n * The body of the downloaded `.txt`.\n *\n * Lines are joined with CRLF rather than LF. The file exists to be opened by a person in whatever\n * their machine hands them, and a LF-only text file is still rendered as one long line by a fair\n * amount of Windows tooling; CRLF is read correctly by every editor on every platform, so it is the\n * ending that cannot be wrong. The clipboard is the opposite case and gets plain LF — see\n * {@link RecoveryCodes}.\n */\nexport function formatCodesText({\n  codes,\n  title,\n  generatedAt = new Date(),\n  note,\n}: RecoveryCodeSheet): string {\n  const lines = [title, `Generated ${isoDate(generatedAt)}`, \"\"]\n  for (const code of codes) lines.push(code)\n  if (note) lines.push(\"\", note)\n  return lines.join(\"\\r\\n\") + \"\\r\\n\"\n}\n\n/** Escapes a string for interpolation into HTML text or a double-quoted attribute. */\nexport function escapeHtml(value: string): string {\n  return value\n    .replace(/&/g, \"&amp;\")\n    .replace(/</g, \"&lt;\")\n    .replace(/>/g, \"&gt;\")\n    .replace(/\"/g, \"&quot;\")\n    .replace(/'/g, \"&#39;\")\n}\n\n/** Marks the sheet's root element so {@link printDocument} can tell it from a blank frame. */\nconst SHEET_MARKER = \"data-recovery-codes\"\n\n/**\n * Builds the standalone document that gets printed.\n *\n * Every colour is stated as black on white. The sheet must not inherit the page's theme: browsers\n * drop background colours when printing but keep text colours, so a dark-mode card sent to a\n * printer comes out as pale grey text on white paper — legible on screen, close to blank on paper,\n * and nobody finds out until they need the codes.\n *\n * The codes are escaped even though a server issues them. This string becomes a document; treating\n * the one value that crosses into it as data rather than markup is the cheap half of that, and it\n * also means a caller free-typing a `title` like `Acme <staging>` gets a sheet instead of a mess.\n */\nexport function buildPrintDocument({\n  codes,\n  title,\n  generatedAt = new Date(),\n  note,\n}: RecoveryCodeSheet): string {\n  const items = codes.map((code) => `<li>${escapeHtml(code)}</li>`).join(\"\")\n  return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>${escapeHtml(title)}</title>\n<style>\n  @page { margin: 16mm; }\n  :root { color-scheme: light; }\n  body { margin: 0; color: #000; background: #fff; font: 14px/1.5 system-ui, -apple-system, \"Segoe UI\", sans-serif; }\n  h1 { margin: 0 0 2px; font-size: 16px; }\n  .meta { margin: 0 0 16px; font-size: 12px; color: #444; }\n  ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 32px; margin: 0; padding: 0; list-style: none; }\n  li { padding-bottom: 3px; border-bottom: 1px dashed #bbb; font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.04em; break-inside: avoid; }\n  .note { max-width: 62ch; margin: 20px 0 0; font-size: 12px; color: #444; }\n</style>\n</head>\n<body ${SHEET_MARKER}>\n<h1>${escapeHtml(title)}</h1>\n<p class=\"meta\">Generated ${isoDate(generatedAt)} &middot; ${codes.length} code${codes.length === 1 ? \"\" : \"s\"}</p>\n<ul>${items}</ul>\n${note ? `<p class=\"note\">${escapeHtml(note)}</p>` : \"\"}\n</body>\n</html>`\n}\n\n/** Firefox needs the object URL to outlive the click's own task before it is released. */\nconst REVOKE_DELAY_MS = 40\n\n/**\n * Saves `text` as a file the browser downloads.\n *\n * The two things a hand-rolled version leaves out are both here. The anchor is put into the\n * document before it is clicked, because Firefox ignores a click on an element that is not in the\n * tree. And the object URL is revoked afterwards: an un-revoked one keeps its blob — which is to\n * say, the recovery codes — alive for the whole life of the document, addressable by anyone who\n * gets hold of the URL. Revoking is deferred rather than immediate, because releasing it in the\n * same task as the click cancels the download it was created for.\n */\nexport function downloadTextFile(filename: string, text: string): void {\n  const url = URL.createObjectURL(new Blob([text], { type: \"text/plain;charset=utf-8\" }))\n  const anchor = document.createElement(\"a\")\n  anchor.href = url\n  anchor.download = filename\n  anchor.rel = \"noopener\"\n  anchor.style.display = \"none\"\n  document.body.appendChild(anchor)\n  anchor.click()\n  anchor.remove()\n  window.setTimeout(() => URL.revokeObjectURL(url), REVOKE_DELAY_MS)\n}\n\n/**\n * How long an unanswered print is given before the frame holding the codes is torn down anyway.\n *\n * Long on purpose. Removing the frame while a print sheet is still open cancels the job, and a\n * cancelled print is a worse outcome than the markup living a little longer, so this only ever\n * fires when `afterprint` never arrives at all.\n */\nconst PRINT_CLEANUP_MS = 60_000\n\n/**\n * Prints `html` as a document of its own, and resolves once the browser is done with it.\n *\n * This exists because `window.print()` is the wrong call, and wrong in a way nobody notices until\n * the codes are needed. It prints the page, not the codes: the nav, the sidebar and the rest of the\n * settings screen come with them, and when the codes are inside a scrolling dialog — which is\n * exactly where a freshly issued set is shown — the printed sheet is clipped to whatever part of\n * that dialog happened to be scrolled into view. Half the codes are simply missing, on paper that\n * looks finished.\n *\n * A detached document sidesteps all of it, and the details below are the ones that make the recipe\n * hold up rather than working on one browser:\n *\n * - The frame is 0×0 and transparent, never `display: none` — a frame that is not being displayed\n *   has nothing to print, and browsers say so by printing a blank page.\n * - `srcdoc` is assigned before the frame is inserted, so the only `load` event is the sheet's.\n *   Inserting first fires one for the initial `about:blank`, and printing on that gives blank\n *   paper; the marker check below refuses that document even if the order is ever changed back.\n * - Cleanup waits for `afterprint`. In Chrome and Firefox `print()` blocks until the dialog is\n *   dismissed, but in Safari it returns immediately, and code that removed the frame on the next\n *   line would be pulling the document out from under a dialog that is still open.\n */\nexport function printDocument(html: string): Promise<void> {\n  return new Promise((resolve) => {\n    const frame = document.createElement(\"iframe\")\n    frame.setAttribute(\"aria-hidden\", \"true\")\n    frame.setAttribute(\"tabindex\", \"-1\")\n    frame.setAttribute(\"title\", \"Print preview\")\n    frame.style.cssText =\n      \"position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none\"\n\n    let settled = false\n    let timer = 0\n    const finish = () => {\n      if (settled) return\n      settled = true\n      window.clearTimeout(timer)\n      frame.remove()\n      resolve()\n    }\n\n    frame.srcdoc = html\n    frame.onload = () => {\n      const frameWindow = frame.contentWindow\n      if (!frameWindow?.document.querySelector(`[${SHEET_MARKER}]`)) return\n      frameWindow.addEventListener(\"afterprint\", finish)\n      timer = window.setTimeout(finish, PRINT_CLEANUP_MS)\n      frameWindow.focus()\n      frameWindow.print()\n    }\n    document.body.appendChild(frame)\n  })\n}\n\nexport interface RecoveryCodesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** The codes to show. Plain strings, or objects carrying a `used` flag. */\n  codes: readonly RecoveryCodeInput[]\n  /** Heading above the list, and the default heading on the saved and printed sheet. */\n  label?: string\n  /** Overrides the heading written onto the saved and printed sheet — usually the app or account. */\n  sheetTitle?: string\n  /** Guidance shown under the list and repeated on every export. Pass `null` to drop it. */\n  note?: string | null\n  /** Name of the downloaded file. */\n  filename?: string\n  /** Which controls to offer, in toolbar order. */\n  actions?: readonly RecoveryCodeAction[]\n  /**\n   * Fired when the user asks for the codes to leave the screen — the signal to unlock an \"I have\n   * saved these\" button. It reports the request, not its outcome: nothing here can know whether a\n   * download was kept or a print dialog was answered.\n   */\n  onExport?: (action: RecoveryCodeAction) => void\n}\n\nconst DEFAULT_NOTE =\n  \"Each code can be used once. Keep them somewhere only you can reach — anyone holding one can sign in without your second factor.\"\n\n/**\n * The sheet of two-factor recovery codes: the list itself, and the three ways out of the screen —\n * copy, download, print — with spent codes struck through.\n *\n * Every export covers the unused codes only. A saved file containing codes that have already been\n * redeemed is worse than no file: it is the right length, so the person counting on it does not\n * find out until one of them is refused. The header states how many are left whenever any have\n * been spent, so the narrowing is visible rather than silent.\n *\n * The clipboard gets the bare codes, one per line, because the place they are being pasted is a\n * password manager's notes field and the heading would be noise there. The file and the sheet get\n * the heading and the date, because those get filed away and have to be identifiable later.\n *\n * Nothing here renders a date, so the component is safe to server-render: the timestamp is taken\n * when a button is pressed, not while rendering, which is the difference between a printed sheet\n * and a hydration mismatch.\n */\nexport function RecoveryCodes({\n  codes,\n  label = \"Recovery codes\",\n  sheetTitle,\n  note = DEFAULT_NOTE,\n  filename = \"recovery-codes.txt\",\n  actions = ACTION_ORDER,\n  onExport,\n  className,\n  ...props\n}: RecoveryCodesProps) {\n  const labelId = React.useId()\n\n  const entries = normalizeCodes(codes)\n  const unused = entries.filter((entry) => !entry.used).map((entry) => entry.code)\n  const spentCount = entries.length - unused.length\n  const title = sheetTitle ?? label\n\n  // LF, not the file's CRLF: this is going into a text field, where a stray carriage return is a\n  // character the next form to read the codes back will not expect.\n  const clipboardText = unused.join(\"\\n\")\n  const sheet = (): RecoveryCodeSheet => ({ codes: unused, title, note })\n\n  const offered = ACTION_ORDER.filter((action) => actions.includes(action))\n  const nothingLeft = unused.length === 0\n  const countLabel = `${unused.length} recovery code${unused.length === 1 ? \"\" : \"s\"}`\n\n  return (\n    <div className={cn(\"w-full rounded-lg border border-border\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-3 py-2\">\n        <p id={labelId} className=\"text-sm font-medium text-foreground\">\n          {label}\n        </p>\n        {spentCount > 0 ? (\n          <p className=\"text-xs tabular-nums text-muted-foreground\">\n            {unused.length} of {entries.length} unused\n          </p>\n        ) : null}\n        <div className=\"ml-auto flex items-center gap-1\">\n          {offered.map((action) =>\n            action === \"copy\" ? (\n              // The notification is taken off the wrapper rather than by handing copy-button an\n              // onClick. That component spreads its props after its own handler, so an onClick\n              // passed to it would replace the clipboard write instead of running beside it — the\n              // button would still look and sound exactly right and copy nothing. The click bubbles\n              // from the button itself, so keyboard activation is included, and a disabled button\n              // emits none at all.\n              <span key={action} className=\"inline-flex\" onClick={() => onExport?.(\"copy\")}>\n                <CopyButton\n                  value={clipboardText}\n                  disabled={nothingLeft}\n                  aria-label={`Copy ${countLabel}`}\n                  title=\"Copy\"\n                />\n              </span>\n            ) : (\n              <button\n                key={action}\n                type=\"button\"\n                disabled={nothingLeft}\n                aria-label={\n                  action === \"download\" ? `Download ${countLabel}` : `Print ${countLabel}`\n                }\n                title={action === \"download\" ? \"Download\" : \"Print\"}\n                onClick={() => {\n                  if (action === \"download\") {\n                    downloadTextFile(filename, formatCodesText(sheet()))\n                  } else {\n                    void printDocument(buildPrintDocument(sheet()))\n                  }\n                  onExport?.(action)\n                }}\n                className=\"inline-flex h-8 w-8 items-center justify-center rounded-md border border-input bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n              >\n                {action === \"download\" ? (\n                  <Download className=\"h-4 w-4\" aria-hidden=\"true\" />\n                ) : (\n                  <Printer className=\"h-4 w-4\" aria-hidden=\"true\" />\n                )}\n              </button>\n            )\n          )}\n        </div>\n      </div>\n\n      {/* `role=\"list\"` is put back by hand because Safari drops list semantics from a <ul> whose\n          list-style is none, which would leave a screen reader with no count of how many codes\n          there are — the one number that matters on this screen. */}\n      <ul\n        role=\"list\"\n        aria-labelledby={labelId}\n        className=\"grid grid-cols-1 gap-x-6 gap-y-1 p-3 sm:grid-cols-2\"\n      >\n        {entries.map((entry, index) => (\n          <li\n            key={index}\n            className={cn(\n              \"font-mono text-sm tracking-wide text-foreground\",\n              entry.used && \"text-muted-foreground line-through\"\n            )}\n          >\n            {entry.code}\n            {/* A line through the text is a paint decision and reaches nobody using a screen\n                reader, so the state is also said in words. */}\n            {entry.used ? <span className=\"sr-only\"> (used)</span> : null}\n          </li>\n        ))}\n      </ul>\n\n      {note ? (\n        <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">{note}</p>\n      ) : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
