{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "secrets-1",
  "title": "Secrets 1: secrets inventory",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An inventory of secrets that never shows a value: name, environment, owner, what reads it, when it was last read and how long since it changed, old ones called out in words, search, filters and Rotate behind a confirmation.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/alert-dialog.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/match-highlight.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/return-focus.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/secrets-1/secrets-1.tsx",
      "content": "// Ballmac UI: Secrets 1. https://ui.ballmac.com/blocks/secrets-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { KeyRound, Loader2, RefreshCw, Search, ShieldAlert } from \"lucide-react\"\n\nimport { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from \"@/components/ballmac/alert-dialog\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { MatchHighlight, matchesAll, queryTokens } from \"@/components/ballmac/match-highlight\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { useReturnFocus } from \"@/lib/ballmac/return-focus\"\nimport { cn } from \"@/lib/utils\"\n\ntype Secrets1Env = \"production\" | \"staging\" | \"development\"\n\ntype Secrets1Secret = {\n  id: string\n  /** The name code reads it by, for example \"STRIPE_SECRET_KEY\". Values are never shown. */\n  name: string\n  environment: Secrets1Env\n  owner: string\n  /** Days since it was last changed. */\n  rotatedDays: number\n  /** When it was last read by something, already formatted, such as \"2 hours ago\". */\n  lastUsed: string\n  /** What reads it, such as \"billing-api\". */\n  usedBy?: string\n}\n\ntype Secrets1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    secrets?: Secrets1Secret[]\n    title?: string\n    /** A secret not changed for this many days is called out. */\n    maxAgeDays?: number\n    /** Called after the confirmation. Return a promise; reject and the secret stays as it was with the message. */\n    onRotate?: (secret: Secrets1Secret) => void | Promise<void>\n  }\n\nconst sample: Secrets1Secret[] = [\n  { id: \"k1\", name: \"STRIPE_SECRET_KEY\", environment: \"production\", owner: \"Billing\", rotatedDays: 214, lastUsed: \"4 minutes ago\", usedBy: \"billing-api\" },\n  { id: \"k2\", name: \"DATABASE_URL\", environment: \"production\", owner: \"Platform\", rotatedDays: 61, lastUsed: \"Just now\", usedBy: \"api, workers\" },\n  { id: \"k3\", name: \"OPENAI_API_KEY\", environment: \"production\", owner: \"AI\", rotatedDays: 33, lastUsed: \"12 minutes ago\", usedBy: \"assistant\" },\n  { id: \"k4\", name: \"SENDGRID_API_KEY\", environment: \"production\", owner: \"Growth\", rotatedDays: 128, lastUsed: \"2 hours ago\", usedBy: \"notifications\" },\n  { id: \"k5\", name: \"STRIPE_SECRET_KEY\", environment: \"staging\", owner: \"Billing\", rotatedDays: 19, lastUsed: \"Yesterday\", usedBy: \"billing-api\" },\n  { id: \"k6\", name: \"JWT_SIGNING_KEY\", environment: \"production\", owner: \"Platform\", rotatedDays: 97, lastUsed: \"Just now\", usedBy: \"auth\" },\n  { id: \"k7\", name: \"S3_ACCESS_KEY\", environment: \"development\", owner: \"Platform\", rotatedDays: 340, lastUsed: \"3 weeks ago\" },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-2\">\n      <Skeleton className=\"h-10 rounded-lg\" />\n      {[0, 1, 2, 3, 4].map((i) => (\n        <Skeleton key={i} className=\"h-16 rounded-lg\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * An inventory of secrets that never shows a value: the name, where it runs, who owns it, how long since it was changed and when it was last read. Old ones are\n * called out in words, and Rotate asks first and says what happens. Search, an environment filter and an oldest-first order help find the ones that matter.\n */\nfunction Secrets1({ secrets: secretsProp = sample, title = \"Secrets\", maxAgeDays = 90, onRotate, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Secrets1Props) {\n  const id = React.useId()\n  const [secrets, setSecrets] = React.useState(secretsProp)\n  React.useEffect(() => setSecrets(secretsProp), [secretsProp])\n  const [query, setQuery] = React.useState(\"\")\n  const [env, setEnv] = React.useState<Secrets1Env | \"all\">(\"all\")\n  const [oldestFirst, setOldestFirst] = React.useState(true)\n  const [confirm, setConfirm] = React.useState<Secrets1Secret | null>(null)\n  const [busy, setBusy] = React.useState<string | null>(null)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const ret = useReturnFocus(!!confirm)\n  const tokens = queryTokens(query)\n  const view = state === \"ready\" && secrets.length === 0 ? \"empty\" : state\n  const stale = (s: Secrets1Secret) => s.rotatedDays >= maxAgeDays\n  const shown = secrets\n    .filter((s) => (env === \"all\" || s.environment === env) && (!tokens.length || matchesAll(`${s.name} ${s.owner} ${s.usedBy ?? \"\"}`, tokens)))\n    .sort((a, b) => (oldestFirst ? b.rotatedDays - a.rotatedDays : a.rotatedDays - b.rotatedDays))\n  const staleCount = secrets.filter(stale).length\n\n  async function rotate(s: Secrets1Secret) {\n    setBusy(s.id)\n    setNote(null)\n    try {\n      await onRotate?.(s)\n      setSecrets((l) => l.map((x) => (x.id === s.id ? { ...x, rotatedDays: 0 } : x)))\n      setNote({ ok: true, text: `${s.name} in ${s.environment} was rotated. Things that read it pick up the new value on their next start.` })\n    } catch (e) {\n      setNote({ ok: false, text: e instanceof Error ? e.message : `${s.name} could not be rotated, so it is as it was.` })\n    } finally {\n      setBusy(null)\n    }\n  }\n\n  return (\n    <section data-slot=\"secrets-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">Where every secret runs and how old it is. Values are never shown here.</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading secrets\" loading={<Skel />} empty={{ title: \"No secrets yet\", description: \"Secrets your services read will be listed here, without their values.\", ...emptyState }} error={{ title: \"Secrets could not be loaded\", description: \"Nothing was rotated or changed. Check your connection and try again.\", ...errorState }}>\n        <div className=\"flex flex-col gap-3 sm:flex-row sm:items-center\">\n          <div className=\"bg-background focus-within:ring-ring/50 flex h-10 flex-1 items-center gap-2 rounded-lg border px-3 focus-within:ring-[3px]\">\n            <Search aria-hidden=\"true\" className=\"text-muted-foreground size-4\" />\n            <input type=\"search\" aria-label=\"Search secrets\" placeholder=\"Search by name, owner or service\" value={query} onChange={(e) => setQuery(e.target.value)} className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-sm outline-none\" />\n          </div>\n          <select aria-label=\"Environment\" value={env} onChange={(e) => setEnv(e.target.value as Secrets1Env | \"all\")} className=\"bg-background focus-visible:ring-ring/50 h-10 rounded-lg border px-3 text-sm outline-none focus-visible:ring-[3px]\">\n            <option value=\"all\">All environments</option>\n            <option value=\"production\">Production</option>\n            <option value=\"staging\">Staging</option>\n            <option value=\"development\">Development</option>\n          </select>\n          <Button variant=\"outline\" aria-pressed={oldestFirst} onClick={() => setOldestFirst((v) => !v)}>{oldestFirst ? \"Oldest first\" : \"Newest first\"}</Button>\n        </div>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-4 text-sm\">\n          {shown.length} {shown.length === 1 ? \"secret\" : \"secrets\"}\n          {staleCount > 0 && <> · <strong className=\"text-foreground\">{staleCount} not changed for {maxAgeDays} days or more</strong></>}\n        </p>\n        {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"mt-2 text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n\n        {shown.length ? (\n          <ul className=\"mt-3 grid gap-2.5\">\n            {shown.map((s) => (\n              <li key={s.id} data-stale={stale(s) || undefined} className={cn(\"bg-card flex flex-wrap items-center justify-between gap-x-6 gap-y-3 rounded-xl border p-4\", stale(s) && \"border-chart-3/70\")}>\n                <div className=\"flex min-w-0 items-start gap-3\">\n                  <span aria-hidden=\"true\" className=\"bg-muted mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg\"><KeyRound className=\"size-4\" /></span>\n                  <div className=\"min-w-0\">\n                    <p className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n                      <code className=\"font-mono text-sm font-semibold\" dir=\"ltr\"><MatchHighlight text={s.name} query={tokens} /></code>\n                      <span className=\"rounded-full border px-2 py-px text-[11px] capitalize\">{s.environment}</span>\n                    </p>\n                    <p className=\"text-muted-foreground mt-1 text-xs\">Owner: <MatchHighlight text={s.owner} query={tokens} />{s.usedBy ? <> · Read by <MatchHighlight text={s.usedBy} query={tokens} /></> : \" · Not read by anything lately\"} · Last read {s.lastUsed.toLowerCase()}</p>\n                    <p className={cn(\"mt-1.5 flex items-center gap-1.5 text-xs tabular-nums\", stale(s) ? \"text-foreground font-medium\" : \"text-muted-foreground\")}>\n                      {stale(s) && <ShieldAlert aria-hidden=\"true\" className=\"text-chart-3 size-3.5\" />}\n                      {s.rotatedDays === 0 ? \"Changed today\" : `Not changed for ${s.rotatedDays} ${s.rotatedDays === 1 ? \"day\" : \"days\"}`}\n                      {stale(s) && \" · due for rotation\"}\n                    </p>\n                  </div>\n                </div>\n                <Button size=\"sm\" variant={stale(s) ? \"default\" : \"outline\"} disabled={busy === s.id} onClick={() => setConfirm(s)} aria-label={`Rotate ${s.name} in ${s.environment}`}>\n                  {busy === s.id ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : <RefreshCw aria-hidden=\"true\" />} Rotate\n                </Button>\n              </li>\n            ))}\n          </ul>\n        ) : (\n          <p className=\"text-muted-foreground mt-3 rounded-xl border border-dashed px-4 py-12 text-center text-sm\">No secrets match. Try another search or environment.</p>\n        )}\n      </StateView>\n\n      <AlertDialog open={!!confirm} onOpenChange={(o) => !o && setConfirm(null)}>\n        <AlertDialogContent {...ret}>\n          <AlertDialogHeader>\n            <AlertDialogTitle>Rotate {confirm?.name}?</AlertDialogTitle>\n            <AlertDialogDescription>\n              A new value is created for {confirm?.environment}. {confirm?.usedBy ? `${confirm.usedBy} picks` : \"Anything that reads it picks\"} it up on its next start, so check that it can restart cleanly first.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel>Cancel</AlertDialogCancel>\n            <AlertDialogAction onClick={() => confirm && void rotate(confirm)}>Rotate</AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </section>\n  )\n}\n\nexport { Secrets1, type Secrets1Env, type Secrets1Props, type Secrets1Secret }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/secrets-1/secrets-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "secrets",
      "rotation",
      "credentials",
      "api keys",
      "security",
      "vault",
      "environment variables",
      "compliance"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "A secrets inventory. <Secrets1 secrets={[{ id, name: 'STRIPE_SECRET_KEY', environment: 'production' | 'staging' | 'development', owner, rotatedDays, lastUsed, usedBy? }]} maxAgeDays={90} onRotate={(secret) => Promise} />. It never takes or shows a secret value. A secret not changed for maxAgeDays or more is called out in words; Rotate opens a confirmation that says what happens, then shows the secret as changed today.",
      "whenToUse": [
        "A security or platform page listing every credential and its age",
        "A rotation checklist for a compliance review"
      ],
      "whenNotToUse": [
        "Editing or revealing values; this is deliberately read-only",
        "API keys for your own users; use api-key-field"
      ],
      "composesWith": [
        "audit-1",
        "access-1",
        "settings-1",
        "alerts-1"
      ],
      "a11y": [
        {
          "keys": "List",
          "action": "Each secret is a list item with its name in code (left to right), environment, owner and age in text"
        },
        {
          "keys": "Old secrets",
          "action": "Written as 'Not changed for 214 days · due for rotation' with an icon, never colour alone"
        },
        {
          "keys": "Rotate",
          "action": "A button named for the secret and environment that opens an alert dialog and returns focus when closed"
        },
        {
          "keys": "Filters",
          "action": "A labelled search and select, and a toggle for the order; the count is a polite announcement"
        }
      ],
      "customization": [
        "maxAgeDays",
        "secrets and their owners",
        "onRotate; reject and the secret stays as it was"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "secrets-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No secrets yet",
        "example": "secrets-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no secrets yet; emptyState sets the copy. A search or filter with no match says so."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "secrets-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was rotated or changed, with Try again (onRetry). A rotation that fails leaves the secret as it was and says why."
      }
    ],
    "examples": [
      "secrets-1-demo",
      "secrets-1-fresh",
      "secrets-1-loading",
      "secrets-1-empty",
      "secrets-1-error"
    ],
    "url": "https://ui.ballmac.com/components/secrets-1"
  },
  "categories": [
    "blocks",
    "secrets"
  ],
  "type": "registry:block"
}