{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "api-key",
  "type": "registry:ui",
  "title": "API Key",
  "description": "Masked secret that scrambles into view, copies, and regenerates behind a confirm.",
  "categories": [
    "settings"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/api-key.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { cn } from \"@/lib/cn\";\nimport { Eye, EyeOff, Loader, Refresh, Warning } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst DOT = \"•\";\nconst GLYPHS = \"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789\";\n\n/** Prefix, then dots, then the last four. As long as the real key, so revealing it never reflows the field. */\nexport function maskKey(key: string, prefix = \"\") {\n  const head = key.startsWith(prefix) ? prefix : \"\";\n  const tail = key.slice(-4);\n  return head + DOT.repeat(Math.max(4, key.length - head.length - 4)) + tail;\n}\n\n/**\n * Animates a string toward a target: each position churns through random\n * glyphs until its turn comes, left to right, then settles. Runs on\n * requestAnimationFrame from the event that started it and cancels cleanly.\n */\nexport function useScramble(initial: string) {\n  const [text, setText] = useState(initial);\n  const frame = useRef(0);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const run = useCallback((from: string, to: string, { duration = 520, instant = false, keep = 0 }: { duration?: number; instant?: boolean; keep?: number } = {}) => {\n    cancelAnimationFrame(frame.current);\n    if (instant) return setText(to);\n    const start = performance.now();\n    const len = to.length;\n    let lastChurn = 0;\n    let churn = from;\n    const tick = (now: number) => {\n      const t = now - start;\n      // Positions under `keep` (the prefix) never scramble; the rest resolve in order.\n      const resolved = Math.min(len, keep + Math.floor(((len - keep) * t) / duration));\n      // Re-roll the unresolved glyphs every ~45ms, so it reads as churning rather than flicker.\n      if (now - lastChurn > 45) {\n        lastChurn = now;\n        // What the mask already showed (prefix, last four) holds still; only the hidden part churns,\n        // and dots far ahead of the sweep wait their turn.\n        churn = Array.from({ length: len }, (_, i) =>\n          i < keep || from[i] === to[i] ? to[i] : from[i] === DOT && i > resolved + 6 ? DOT : GLYPHS[(Math.random() * GLYPHS.length) | 0],\n        ).join(\"\");\n      }\n      setText(to.slice(0, resolved) + churn.slice(resolved));\n      if (resolved < len) frame.current = requestAnimationFrame(tick);\n      else setText(to);\n    };\n    frame.current = requestAnimationFrame(tick);\n  }, []);\n\n  return [text, run] as const;\n}\n\n// Ticks every 30s so \"Last used 4m ago\" stays true. Null on the server.\nconst subscribeClock = (fn: () => void) => {\n  const t = window.setInterval(fn, 30_000);\n  return () => window.clearInterval(t);\n};\nfunction useNow() {\n  return useSyncExternalStore(\n    subscribeClock,\n    () => Math.floor(Date.now() / 30_000) * 30_000,\n    () => null,\n  );\n}\n\nfunction relative(date: Date, now: number) {\n  const s = Math.round((date.getTime() - now) / 1000);\n  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\", style: \"short\" });\n  const abs = Math.abs(s);\n  if (abs < 60) return \"just now\";\n  if (abs < 3600) return rtf.format(Math.round(s / 60), \"minute\");\n  if (abs < 86_400) return rtf.format(Math.round(s / 3600), \"hour\");\n  if (abs < 7 * 86_400) return rtf.format(Math.round(s / 86_400), \"day\");\n  return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" }).format(date);\n}\n\nconst toDate = (d: Date | string | number) => (d instanceof Date ? d : new Date(d));\n\nexport type ApiKeyProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** What the key is for: Production, CI runner, Zapier. */\n  name: React.ReactNode;\n  /** The full secret, when you're allowed to show it. Leave it out and pass `last4` to only ever show the mask. */\n  value?: string;\n  /** Last four characters, for keys whose full value you don't have. */\n  last4?: string;\n  /** Stays readable in the mask: sk_demo_, pk_test_. */\n  prefix?: string;\n  /** Fetch the full key on reveal (behind re-auth, say). Used when `value` isn't given. */\n  onReveal?: () => Promise<string>;\n  /** Show `value` as a brand-new key: revealed, with a warning that it won't be shown again. */\n  once?: boolean;\n  /** Called when the person confirms they've saved a once-only key. The key is forgotten after this. */\n  onDismissOnce?: () => void;\n  /** Resolve with the new full key. The old one is shown until it resolves; a rejection keeps it. */\n  onRegenerate?: () => Promise<string>;\n  createdAt?: Date | string | number;\n  /** `null` for a key that has never been used. */\n  lastUsedAt?: Date | string | number | null;\n  /** Container for the confirm popover. */\n  container?: Popover.Portal.Props[\"container\"];\n};\n\nexport function ApiKey({\n  name,\n  value,\n  last4,\n  prefix = \"\",\n  onReveal,\n  once = false,\n  onDismissOnce,\n  onRegenerate,\n  createdAt,\n  lastUsedAt,\n  container,\n  className,\n  ...rest\n}: ApiKeyProps) {\n  const reduce = !!useReducedMotion();\n  const now = useNow();\n  const warningId = useId();\n  // The secret we currently hold. A once-only key is dropped when dismissed.\n  const [secret, setSecret] = useState<string | null>(value ?? null);\n  const [tail, setTail] = useState(value ? value.slice(-4) : (last4 ?? \"\"));\n  const [fresh, setFresh] = useState(once && !!value);\n  const [revealed, setRevealed] = useState(once && !!value);\n  const [revealing, setRevealing] = useState(false);\n  const [revealError, setRevealError] = useState(false);\n  const fallbackMask = prefix + DOT.repeat(24) + tail;\n  const masked = secret ? maskKey(secret, prefix) : fallbackMask;\n  const [text, scramble] = useScramble(revealed && secret ? secret : masked);\n\n  const canReveal = !!secret || !!onReveal;\n\n  const show = (key: string) => {\n    setSecret(key);\n    setRevealed(true);\n    scramble(maskKey(key, prefix), key, { instant: reduce, keep: prefix.length });\n  };\n\n  const toggle = async () => {\n    setRevealError(false);\n    if (revealed) {\n      setRevealed(false);\n      // Hiding is not a moment: it happens now.\n      scramble(text, masked, { instant: true });\n      return;\n    }\n    if (secret) return show(secret);\n    if (!onReveal) return;\n    setRevealing(true);\n    try {\n      show(await onReveal());\n    } catch {\n      setRevealError(true);\n    } finally {\n      setRevealing(false);\n    }\n  };\n\n  const dismissOnce = () => {\n    setFresh(false);\n    setRevealed(false);\n    // A once-only key is never kept around after the person says they've saved it.\n    setSecret(null);\n    scramble(text, secret ? maskKey(secret, prefix) : fallbackMask, { instant: true });\n    onDismissOnce?.();\n  };\n\n  const regenerated = (key: string) => {\n    setTail(key.slice(-4));\n    setFresh(true);\n    setRevealError(false);\n    setSecret(key);\n    setRevealed(true);\n    scramble(secret ? maskKey(secret, prefix) : fallbackMask, key, { instant: reduce, keep: prefix.length, duration: 640 });\n  };\n\n  const copyRef = useRef<HTMLButtonElement>(null);\n  const copyValue = secret ?? onReveal ?? \"\";\n\n  return (\n    <div\n      data-state={fresh ? \"new\" : revealed ? \"revealed\" : \"masked\"}\n      className={cn(\"flex w-full min-w-0 flex-col gap-3 rounded-xl border border-line bg-raised p-4 shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <div className=\"flex min-w-0 items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h3 className=\"truncate text-[13px] font-medium leading-5 tracking-[-0.005em] text-fg\">{name}</h3>\n          <p className=\"flex min-w-0 flex-col text-[12px] leading-[18px] text-fg-3 tabular min-[420px]:flex-row min-[420px]:gap-x-1.5\">\n            {createdAt != null && (\n              <span suppressHydrationWarning>\n                Created {new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" }).format(toDate(createdAt))}\n              </span>\n            )}\n            {createdAt != null && lastUsedAt !== undefined && <span aria-hidden className=\"hidden text-fg-4 min-[420px]:inline\">·</span>}\n            {lastUsedAt === null && <span>Never used</span>}\n            {lastUsedAt != null && (\n              <span suppressHydrationWarning title={toDate(lastUsedAt).toLocaleString()}>\n                Last used {now == null ? new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" }).format(toDate(lastUsedAt)) : relative(toDate(lastUsedAt), now)}\n              </span>\n            )}\n          </p>\n        </div>\n        {onRegenerate && <Regenerate onRegenerate={onRegenerate} onDone={regenerated} copyRef={copyRef} container={container} disabled={fresh} />}\n      </div>\n\n      <div\n        className={cn(\n          \"flex min-h-10 items-center gap-1 rounded-lg border bg-frame py-1 pr-1 pl-3 transition-[border-color] duration-200\",\n          fresh ? \"border-warning/40\" : \"border-line\",\n        )}\n      >\n        {/* Break anywhere so a long key wraps rather than hides its end. The mask is as long as the key, so nothing reflows on reveal. */}\n        <code\n          aria-label={revealed ? undefined : `Hidden key ending in ${tail}`}\n          aria-describedby={fresh ? warningId : undefined}\n          className={cn(\"min-w-0 flex-1 break-all py-1 font-mono text-[12px] leading-[18px] tracking-[0.01em]\", revealed ? \"text-fg\" : \"text-fg-2\")}\n        >\n          {text}\n        </code>\n        {canReveal && (\n          <button\n            type=\"button\"\n            onClick={toggle}\n            aria-label={revealed ? \"Hide key\" : \"Reveal key\"}\n            aria-pressed={revealed}\n            aria-busy={revealing || undefined}\n            className={cn(\n              \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n              \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.92] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n            )}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={revealing ? \"busy\" : revealed ? \"hide\" : \"show\"}\n                className=\"grid place-items-center\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" }}\n                transition={reduce ? { duration: 0.12 } : spring.pop}\n              >\n                {revealing ? <Loader size={14} className=\"animate-spin-slow\" /> : revealed ? <EyeOff size={14} /> : <Eye size={14} />}\n              </motion.span>\n            </AnimatePresence>\n          </button>\n        )}\n        {copyValue !== \"\" && <CopyButton ref={copyRef} value={copyValue} size=\"sm\" variant=\"ghost\" iconOnly label=\"Copy key\" copiedLabel=\"Key copied\" failedLabel=\"Couldn’t copy key\" />}\n      </div>\n\n      {revealError && (\n        <p role=\"alert\" className=\"-mt-1 text-[12.5px] leading-[18px] text-danger\">\n          Couldn’t reveal the key. Try again.\n        </p>\n      )}\n\n      <AnimatePresence initial={false}>\n        {fresh && (\n          <motion.div\n            key=\"once\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, marginTop: -12 }}\n            animate={{ opacity: 1, height: \"auto\", marginTop: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, marginTop: -12, transition: { duration: 0.18, ease: ease.out } }}\n            transition={{ duration: 0.28, ease: ease.out }}\n            className=\"overflow-hidden\"\n          >\n            <div id={warningId} role=\"status\" className=\"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg bg-warning-soft py-2 pr-2 pl-3\">\n              <p className=\"flex min-w-0 flex-[1_1_14rem] items-start gap-2 text-[12.5px] leading-[18px] text-warning\">\n                <Warning size={14} className=\"mt-0.5 shrink-0\" />\n                <span>\n                  <span className=\"font-medium\">Copy this key now.</span> You won’t be able to see it again.\n                </span>\n              </p>\n              <button\n                type=\"button\"\n                onClick={dismissOnce}\n                className={cn(\n                  \"ml-auto inline-flex h-7 shrink-0 items-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)] outline-none\",\n                  \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                )}\n              >\n                I’ve saved it\n              </button>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Regenerate({\n  onRegenerate,\n  onDone,\n  copyRef,\n  container,\n  disabled,\n}: {\n  onRegenerate: () => Promise<string>;\n  onDone: (key: string) => void;\n  copyRef: React.RefObject<HTMLButtonElement | null>;\n  container?: Popover.Portal.Props[\"container\"];\n  disabled: boolean;\n}) {\n  const [open, setOpen] = useState(false);\n  const [pending, setPending] = useState(false);\n  const [error, setError] = useState(false);\n  const succeeded = useRef(false);\n  const cancelRef = useRef<HTMLButtonElement>(null);\n\n  const confirm = async () => {\n    setPending(true);\n    setError(false);\n    try {\n      const key = await onRegenerate();\n      succeeded.current = true;\n      onDone(key);\n      setOpen(false);\n    } catch {\n      setError(true);\n    } finally {\n      setPending(false);\n    }\n  };\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => {\n        // The request is in flight: closing now would hide whether it worked.\n        if (pending) return;\n        if (next) {\n          setError(false);\n          succeeded.current = false;\n        }\n        setOpen(next);\n      }}\n    >\n      <Popover.Trigger\n        disabled={disabled}\n        className={cn(\n          \"group/regen -my-1 -mr-1 inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none\",\n          \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75 data-popup-open:bg-hover data-popup-open:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-50\",\n        )}\n      >\n        {/* The arrow winds back a half turn while the question is open. */}\n        <Refresh size={14} className=\"transition-transform duration-300 ease-out-expo group-data-popup-open/regen:-rotate-180 motion-reduce:transition-none\" />\n        Regenerate\n      </Popover.Trigger>\n      <Popover.Portal container={container}>\n        <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            initialFocus={cancelRef}\n            // After a new key arrives, focus lands on its copy button: the next thing anyone does.\n            finalFocus={() => (succeeded.current ? copyRef.current : true)}\n            className={cn(\n              \"w-[min(300px,var(--available-width))] origin-(--transform-origin) rounded-xl border border-line-2 bg-raised p-3.5 text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <Popover.Title className=\"text-[13px] font-medium leading-5 text-fg\">Regenerate this key?</Popover.Title>\n            <Popover.Description className=\"mt-1 text-[12.5px] leading-[18px] text-fg-3\">\n              Anything using the current key stops working right away.\n            </Popover.Description>\n            {error && (\n              <p role=\"alert\" className=\"mt-2 text-[12.5px] leading-[18px] text-danger\">\n                Couldn’t regenerate the key. Try again.\n              </p>\n            )}\n            <div className=\"mt-3 flex justify-end gap-2\">\n              <Popover.Close\n                ref={cancelRef}\n                disabled={pending}\n                className={cn(\n                  \"inline-flex h-7 items-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg outline-none\",\n                  \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.97] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 disabled:opacity-50\",\n                )}\n              >\n                Cancel\n              </Popover.Close>\n              <button\n                type=\"button\"\n                onClick={confirm}\n                aria-busy={pending || undefined}\n                className={cn(\n                  \"relative inline-grid h-7 items-center rounded-md bg-danger px-2.5 text-[12px] font-medium text-frame outline-none\",\n                  \"transition-[opacity,scale] duration-150 hover:opacity-90 active:scale-[0.97] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                  pending && \"pointer-events-none\",\n                )}\n              >\n                {/* The spinner overlays the label's cell, so the button keeps its width. */}\n                <span className={cn(\"col-start-1 row-start-1 transition-opacity duration-150\", pending && \"opacity-0\")}>Regenerate</span>\n                <span className={cn(\"col-start-1 row-start-1 grid place-items-center transition-opacity duration-150\", !pending && \"opacity-0\")}>\n                  <Loader size={14} className=\"animate-spin-slow\" />\n                </span>\n              </button>\n            </div>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/api-key"
}
