{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "credentials",
  "title": "Credentials",
  "description": "The account-credentials kit for self-serve portals: an API-keys card (create with a shown-once secret, expiry/last-used list, revoke with confirm) and a connected-apps card (OAuth consents with scopes and revoke). Data-driven — wire callbacks to your auth client.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/alert-dialog",
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/card",
    "@nx-ui/input",
    "@nx-ui/label",
    "@nx-ui/skeleton",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/credentials/api-keys-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertTriangle,\n  Check,\n  Copy01,\n  Key01,\n  Plus,\n  Trash01,\n} from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\n/**\n * One row in an {@link ApiKeysCard} list — the non-secret fields of a central\n * user API key (a personal access token). The raw secret is only ever\n * available at creation time (see {@link CreatedApiKey}).\n */\nexport type ApiKeySummary = {\n  id: string\n  name: string | null\n  /** The first few characters of the key (including its prefix), e.g. `nxid_k7f2…`. */\n  start: string | null\n  createdAt: string | Date\n  expiresAt: string | Date | null\n  lastUsedAt?: string | Date | null\n}\n\n/** Input for creating an API key — a display name plus an optional lifetime. */\nexport type CreateApiKeyInput = {\n  name: string\n  /** Lifetime in whole days, or `null` to use the server's default. */\n  expiresInDays: number | null\n}\n\n/**\n * The result of a successful create: the raw key, returned exactly once.\n * {@link ApiKeysCard} shows it in a shown-once panel and never retains it.\n */\nexport type CreatedApiKey = {\n  id: string\n  key: string\n}\n\nconst SECONDS_PER_DAY = 24 * 60 * 60\n\nfunction formatDate(value: string | Date | null | undefined): string {\n  if (value == null) return \"Never\"\n  const date = typeof value === \"string\" ? new Date(value) : value\n  if (Number.isNaN(date.getTime())) return \"Unknown\"\n  return new Intl.DateTimeFormat(\"en-GB\", { dateStyle: \"medium\" }).format(date)\n}\n\nfunction expiryState(expiresAt: string | Date | null): \"active\" | \"expiring\" | \"expired\" | \"never\" {\n  if (expiresAt == null) return \"never\"\n  const time = new Date(expiresAt).getTime()\n  if (Number.isNaN(time)) return \"never\"\n  const now = Date.now()\n  if (time <= now) return \"expired\"\n  if (time - now <= 14 * SECONDS_PER_DAY * 1000) return \"expiring\"\n  return \"active\"\n}\n\nconst EXPIRY_BADGE: Record<\n  ReturnType<typeof expiryState>,\n  { label: string; variant: \"gray\" | \"success\" | \"warning\" | \"error\" } | null\n> = {\n  active: null,\n  expiring: { label: \"Expiring soon\", variant: \"warning\" },\n  expired: { label: \"Expired\", variant: \"error\" },\n  never: { label: \"No expiry\", variant: \"gray\" },\n}\n\n/**\n * ApiKeysCard — self-serve management of a user's central API keys (personal\n * access tokens for CLIs and scripts): a create form (name + expiry in days),\n * a shown-once secret panel with copy button and warning, the key list (name,\n * `start` prefix, created/expiry/last-used) and revoke-with-confirm rows.\n *\n * Data-driven like every nx-ui block: the consumer owns the data and supplies\n * async callbacks; the block owns the interaction states. Callbacks should\n * throw on failure — the block catches and renders the message inline.\n *\n * Built to be wired to Better Auth's `apiKeyClient` (create/list/delete),\n * e.g.:\n *\n * @example\n * <ApiKeysCard\n *   keys={keys}\n *   onCreateKey={async ({ name, expiresInDays }) => {\n *     const { data, error } = await authClient.apiKey.create({\n *       name,\n *       expiresIn: expiresInDays == null ? null : expiresInDays * 86400,\n *     })\n *     if (error) throw new Error(error.message)\n *     return { id: data.id, key: data.key }\n *   }}\n *   onRevokeKey={async (keyId) => {\n *     const { error } = await authClient.apiKey.delete({ keyId })\n *     if (error) throw new Error(error.message)\n *   }}\n * />\n */\nfunction ApiKeysCard({\n  keys,\n  loading = false,\n  onCreateKey,\n  onRevokeKey,\n  defaultExpiryDays = 90,\n  minExpiryDays = 1,\n  maxExpiryDays = 365,\n  keyPrefix = \"nxid_\",\n  className,\n}: {\n  /** The user's keys. Omit or pass `loading` while fetching. */\n  keys: ApiKeySummary[]\n  loading?: boolean\n  /** Creates a key; must resolve with the shown-once raw key. Throw on failure. */\n  onCreateKey?: (input: CreateApiKeyInput) => Promise<CreatedApiKey>\n  /** Revokes (deletes) a key by id. Throw on failure. */\n  onRevokeKey?: (keyId: string) => Promise<void>\n  /** Pre-filled lifetime in days — match the server's default (nx-id: 90). */\n  defaultExpiryDays?: number\n  /** Lifetime bounds in days — match the server's policy (nx-id: 1–365). */\n  minExpiryDays?: number\n  maxExpiryDays?: number\n  /** The key prefix, used in the shown-once warning copy (nx-id: `nxid_`). */\n  keyPrefix?: string\n  className?: string\n}) {\n  const [formOpen, setFormOpen] = React.useState(false)\n  const [name, setName] = React.useState(\"\")\n  const [expiryDays, setExpiryDays] = React.useState(String(defaultExpiryDays))\n  const [creating, setCreating] = React.useState(false)\n  const [createError, setCreateError] = React.useState<string | null>(null)\n  const [created, setCreated] = React.useState<CreatedApiKey | null>(null)\n  const [copied, setCopied] = React.useState(false)\n  const [revokeTarget, setRevokeTarget] = React.useState<ApiKeySummary | null>(null)\n  const [revoking, setRevoking] = React.useState(false)\n  const [actionError, setActionError] = React.useState<string | null>(null)\n  const fieldId = React.useId()\n  const nameId = `${fieldId}-name`\n  const expiryId = `${fieldId}-expiry`\n\n  const expiryValue = Number.parseInt(expiryDays, 10)\n  const expiryValid =\n    expiryDays.trim() === \"\" ||\n    (Number.isFinite(expiryValue) && expiryValue >= minExpiryDays && expiryValue <= maxExpiryDays)\n\n  async function handleCreate(event: React.FormEvent) {\n    event.preventDefault()\n    if (!onCreateKey || creating) return\n    setCreating(true)\n    setCreateError(null)\n    try {\n      const result = await onCreateKey({\n        name: name.trim(),\n        expiresInDays: expiryDays.trim() === \"\" ? null : expiryValue,\n      })\n      setCreated(result)\n      setFormOpen(false)\n      setName(\"\")\n      setExpiryDays(String(defaultExpiryDays))\n    } catch (error) {\n      setCreateError(error instanceof Error ? error.message : \"Couldn't create the key. Try again.\")\n    } finally {\n      setCreating(false)\n    }\n  }\n\n  async function handleCopy() {\n    if (!created) return\n    try {\n      await navigator.clipboard.writeText(created.key)\n      setCopied(true)\n      window.setTimeout(() => setCopied(false), 2000)\n    } catch {\n      // Clipboard unavailable (permissions) — the key stays selectable.\n    }\n  }\n\n  async function handleRevoke() {\n    if (!revokeTarget || !onRevokeKey || revoking) return\n    setRevoking(true)\n    setActionError(null)\n    try {\n      await onRevokeKey(revokeTarget.id)\n      setRevokeTarget(null)\n    } catch (error) {\n      setActionError(error instanceof Error ? error.message : \"Couldn't revoke the key. Try again.\")\n    } finally {\n      setRevoking(false)\n    }\n  }\n\n  return (\n    <Card data-slot=\"api-keys-card\" className={cn(\"gap-0 p-0\", className)}>\n      <div className=\"flex flex-col gap-4 border-b border-border p-6 sm:flex-row sm:items-start sm:justify-between\">\n        <div className=\"flex max-w-md flex-col gap-1\">\n          <h2 className=\"text-md font-semibold text-foreground\">API keys</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            Personal access tokens for CLIs and scripts. Send a key as the{\" \"}\n            <code className=\"rounded bg-muted px-1 py-0.5 font-mono text-xs whitespace-nowrap\">\n              x-api-key\n            </code>{\" \"}\n            header — it acts as you, so keep it secret.\n          </p>\n        </div>\n        {!formOpen && !created ? (\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            className=\"shrink-0 self-start\"\n            onClick={() => {\n              setCreateError(null)\n              setFormOpen(true)\n            }}\n          >\n            <Plus />\n            Create API key\n          </Button>\n        ) : null}\n      </div>\n\n      <CardContent className=\"flex flex-col p-0\">\n        {created ? (\n          <div data-slot=\"api-keys-created\" className=\"flex flex-col gap-4 p-6\">\n            <div className=\"flex items-start gap-3 rounded-lg border border-utility-yellow-200 bg-utility-yellow-50 p-4\">\n              <AlertTriangle\n                className=\"mt-0.5 size-5 shrink-0 text-fg-warning-primary\"\n                aria-hidden\n              />\n              <div className=\"flex flex-col gap-1\">\n                <span className=\"text-sm font-semibold text-foreground\">\n                  Copy your key now — it won&apos;t be shown again\n                </span>\n                <span className=\"text-sm text-muted-foreground\">\n                  This is the only time the full key is displayed. Store it somewhere safe (a\n                  password manager or a CI secret); anyone holding it can act as you until it\n                  expires or is revoked.\n                </span>\n              </div>\n            </div>\n            <div className=\"flex flex-col gap-3 sm:flex-row\">\n              <Input\n                readOnly\n                value={created.key}\n                aria-label=\"Your new API key\"\n                className=\"font-mono text-xs\"\n                onFocus={(event) => event.target.select()}\n              />\n              <div className=\"flex shrink-0 gap-2\">\n                <Button type=\"button\" variant=\"secondary\" onClick={() => void handleCopy()}>\n                  {copied ? <Check /> : <Copy01 />}\n                  {copied ? \"Copied\" : \"Copy\"}\n                </Button>\n                <Button type=\"button\" onClick={() => setCreated(null)}>\n                  Done\n                </Button>\n              </div>\n            </div>\n          </div>\n        ) : null}\n\n        {formOpen && !created ? (\n          <form\n            data-slot=\"api-keys-create-form\"\n            className=\"flex flex-col gap-4 border-b border-border bg-muted/40 p-6\"\n            onSubmit={(event) => void handleCreate(event)}\n          >\n            <div className=\"flex flex-col gap-4 sm:flex-row\">\n              <div className=\"flex flex-1 flex-col gap-1.5\">\n                <Label htmlFor={nameId}>Key name</Label>\n                <Input\n                  id={nameId}\n                  required\n                  maxLength={80}\n                  placeholder=\"e.g. laptop CLI\"\n                  value={name}\n                  onChange={(event) => setName(event.target.value)}\n                />\n                <span className=\"text-xs text-muted-foreground\">\n                  A label so you remember what it&apos;s for.\n                </span>\n              </div>\n              <div className=\"flex flex-col gap-1.5 sm:w-44\">\n                <Label htmlFor={expiryId}>Expires after (days)</Label>\n                <Input\n                  id={expiryId}\n                  type=\"number\"\n                  inputMode=\"numeric\"\n                  min={minExpiryDays}\n                  max={maxExpiryDays}\n                  placeholder={String(defaultExpiryDays)}\n                  value={expiryDays}\n                  onChange={(event) => setExpiryDays(event.target.value)}\n                  aria-invalid={!expiryValid}\n                />\n                <span className=\"text-xs text-muted-foreground\">\n                  {minExpiryDays}–{maxExpiryDays}; blank uses the {defaultExpiryDays}-day default.\n                </span>\n              </div>\n            </div>\n            {createError ? (\n              <p role=\"alert\" className=\"text-sm text-destructive\">\n                {createError}\n              </p>\n            ) : null}\n            <div className=\"flex gap-2\">\n              <Button\n                type=\"submit\"\n                size=\"sm\"\n                disabled={creating || !name.trim() || !expiryValid}\n              >\n                {creating ? \"Creating…\" : \"Create key\"}\n              </Button>\n              <Button\n                type=\"button\"\n                size=\"sm\"\n                variant=\"ghost\"\n                disabled={creating}\n                onClick={() => {\n                  setFormOpen(false)\n                  setCreateError(null)\n                }}\n              >\n                Cancel\n              </Button>\n            </div>\n          </form>\n        ) : null}\n\n        {actionError ? (\n          <p role=\"alert\" className=\"border-b border-border px-6 py-3 text-sm text-destructive\">\n            {actionError}\n          </p>\n        ) : null}\n\n        {loading ? (\n          <div className=\"flex flex-col gap-3 p-6\">\n            <Skeleton className=\"h-12 w-full\" />\n            <Skeleton className=\"h-12 w-full\" />\n          </div>\n        ) : keys.length === 0 ? (\n          <div className=\"flex flex-col items-center gap-3 px-6 py-12 text-center\">\n            <span className=\"flex size-10 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground\">\n              <Key01 className=\"size-5\" aria-hidden />\n            </span>\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-semibold text-foreground\">\n                No API keys yet\n              </p>\n              <p className=\"max-w-sm text-sm text-balance text-muted-foreground\">\n                Create a{\" \"}\n                <span className=\"font-mono whitespace-nowrap\">\n                  {keyPrefix}…\n                </span>{\" \"}\n                key to authenticate CLIs and scripts as you.\n              </p>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col divide-y divide-border\">\n            {keys.map((key) => {\n              const state = expiryState(key.expiresAt)\n              const badge = EXPIRY_BADGE[state]\n              return (\n                <div\n                  key={key.id}\n                  data-slot=\"api-key-row\"\n                  className=\"flex items-center gap-3 px-6 py-4\"\n                >\n                  <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                    <div className=\"flex flex-wrap items-center gap-2\">\n                      <span className=\"truncate text-sm font-semibold text-foreground\">\n                        {key.name ?? \"Unnamed key\"}\n                      </span>\n                      {badge ? <Badge variant={badge.variant}>{badge.label}</Badge> : null}\n                    </div>\n                    <span className=\"truncate font-mono text-xs text-muted-foreground\">\n                      {key.start ? `${key.start}…` : keyPrefix}\n                    </span>\n                    <span className=\"text-xs text-muted-foreground\">\n                      Created {formatDate(key.createdAt)} ·{\" \"}\n                      {state === \"expired\"\n                        ? `Expired ${formatDate(key.expiresAt)}`\n                        : key.expiresAt\n                          ? `Expires ${formatDate(key.expiresAt)}`\n                          : \"Never expires\"}{\" \"}\n                      · Last used {formatDate(key.lastUsedAt)}\n                    </span>\n                  </div>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                    onClick={() => {\n                      setActionError(null)\n                      setRevokeTarget(key)\n                    }}\n                  >\n                    <Trash01 />\n                    Revoke\n                  </Button>\n                </div>\n              )\n            })}\n          </div>\n        )}\n      </CardContent>\n\n      <AlertDialog\n        open={revokeTarget !== null}\n        onOpenChange={(open) => {\n          if (!revoking && !open) setRevokeTarget(null)\n        }}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>\n              Revoke {revokeTarget?.name ?? \"this API key\"}?\n            </AlertDialogTitle>\n            <AlertDialogDescription>\n              Anything using this key (CLIs, scripts, CI) will stop working immediately. This\n              cannot be undone — you&apos;d need to create a new key.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={revoking}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              variant=\"destructive\"\n              disabled={revoking}\n              onClick={(event) => {\n                event.preventDefault()\n                void handleRevoke()\n              }}\n            >\n              {revoking ? \"Revoking…\" : \"Revoke key\"}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </Card>\n  )\n}\n\nexport { ApiKeysCard }\n",
      "type": "registry:component",
      "target": "components/blocks/credentials/api-keys-card.tsx"
    },
    {
      "path": "components/blocks/credentials/connected-apps-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Globe01, LinkBroken01, Trash01 } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, CardContent } from \"@/components/ui/card\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\n\n/**\n * One row in a {@link ConnectedAppsCard} list: an OAuth consent the user has\n * granted to a third-party client (an MCP client or a connected OIDC product).\n */\nexport type ConnectedApp = {\n  /** The consent/grant id — the handle revocation acts on. */\n  id: string\n  /** The OAuth client_id the consent was granted to. */\n  clientId: string\n  /** Public client metadata, when the consumer could resolve it. */\n  clientName?: string | null\n  clientUri?: string | null\n  /** The scope names granted, e.g. `[\"openid\", \"profile\", \"nexus:read\"]`. */\n  scopes: string[]\n  grantedAt: string | Date\n}\n\nfunction formatDate(value: string | Date): string {\n  const date = typeof value === \"string\" ? new Date(value) : value\n  if (Number.isNaN(date.getTime())) return \"Unknown\"\n  return new Intl.DateTimeFormat(\"en-GB\", { dateStyle: \"medium\" }).format(date)\n}\n\nfunction hostOf(uri: string | null | undefined): string | null {\n  if (!uri) return null\n  try {\n    return new URL(uri).host\n  } catch {\n    return null\n  }\n}\n\n/**\n * ConnectedAppsCard — the user's OAuth consents (\"Sign in with Nexus ID\"\n * grants): one row per authorised app showing client name/host, the scopes\n * granted and the grant date, with revoke-with-confirm.\n *\n * Data-driven like every nx-ui block: the consumer fetches consents (Better\n * Auth oauth-provider: `GET /api/auth/oauth2/get-consents`, hydrating client\n * metadata via `GET /api/auth/oauth2/public-client?client_id=…`) and supplies\n * the revoke callback (`POST /api/auth/oauth2/delete-consent`). Callbacks\n * should throw on failure — the block catches and renders the message inline.\n *\n * @example\n * <ConnectedAppsCard\n *   apps={apps}\n *   onRevokeApp={async (consentId) => {\n *     const { error } = await authClient.$fetch(\"/oauth2/delete-consent\", {\n *       method: \"POST\",\n *       body: { id: consentId },\n *     })\n *     if (error) throw new Error(error.message)\n *   }}\n * />\n */\nfunction ConnectedAppsCard({\n  apps,\n  loading = false,\n  onRevokeApp,\n  className,\n}: {\n  /** The user's granted consents. */\n  apps: ConnectedApp[]\n  loading?: boolean\n  /** Revokes a consent by its id. Throw on failure. */\n  onRevokeApp?: (consentId: string) => Promise<void>\n  className?: string\n}) {\n  const [revokeTarget, setRevokeTarget] = React.useState<ConnectedApp | null>(null)\n  const [revoking, setRevoking] = React.useState(false)\n  const [actionError, setActionError] = React.useState<string | null>(null)\n\n  async function handleRevoke() {\n    if (!revokeTarget || !onRevokeApp || revoking) return\n    setRevoking(true)\n    setActionError(null)\n    try {\n      await onRevokeApp(revokeTarget.id)\n      setRevokeTarget(null)\n    } catch (error) {\n      setActionError(\n        error instanceof Error ? error.message : \"Couldn't disconnect the app. Try again.\"\n      )\n    } finally {\n      setRevoking(false)\n    }\n  }\n\n  return (\n    <Card data-slot=\"connected-apps-card\" className={cn(\"gap-0 p-0\", className)}>\n      <div className=\"flex flex-col gap-4 border-b border-border p-6 sm:flex-row sm:items-start sm:justify-between\">\n        <div className=\"flex max-w-md flex-col gap-1\">\n          <h2 className=\"text-md font-semibold text-foreground\">Connected apps</h2>\n          <p className=\"text-sm text-muted-foreground\">\n            Third-party apps you&apos;ve authorised with your Nexus ID — MCP clients and\n            connected products. Revoking stops the app from acting as you; authorise it again\n            from the app to reconnect.\n          </p>\n        </div>\n      </div>\n\n      <CardContent className=\"flex flex-col p-0\">\n        {actionError ? (\n          <p role=\"alert\" className=\"border-b border-border px-6 py-3 text-sm text-destructive\">\n            {actionError}\n          </p>\n        ) : null}\n\n        {loading ? (\n          <div className=\"flex flex-col gap-3 p-6\">\n            <Skeleton className=\"h-12 w-full\" />\n            <Skeleton className=\"h-12 w-full\" />\n          </div>\n        ) : apps.length === 0 ? (\n          <div className=\"flex flex-col items-center gap-3 px-6 py-12 text-center\">\n            <span className=\"flex size-10 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground\">\n              <Globe01 className=\"size-5\" aria-hidden />\n            </span>\n            <div className=\"flex flex-col gap-1\">\n              <p className=\"text-sm font-semibold text-foreground\">\n                No connected apps\n              </p>\n              <p className=\"max-w-sm text-sm text-balance text-muted-foreground\">\n                When you grant an app access with &ldquo;Sign in with Nexus\n                ID&rdquo;, it appears here.\n              </p>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex flex-col divide-y divide-border\">\n            {apps.map((app) => {\n              const host = hostOf(app.clientUri)\n              return (\n                <div\n                  key={app.id}\n                  data-slot=\"connected-app-row\"\n                  className=\"flex items-center gap-3 px-6 py-4\"\n                >\n                  <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                    <div className=\"flex flex-wrap items-center gap-2\">\n                      <span className=\"truncate text-sm font-semibold text-foreground\">\n                        {app.clientName ?? app.clientId}\n                      </span>\n                      {host ? (\n                        <span className=\"truncate text-xs text-muted-foreground\">{host}</span>\n                      ) : null}\n                    </div>\n                    {app.scopes.length > 0 ? (\n                      <div className=\"flex flex-wrap gap-1\">\n                        {app.scopes.map((scope) => (\n                          <Badge key={scope} variant=\"gray\">\n                            {scope}\n                          </Badge>\n                        ))}\n                      </div>\n                    ) : null}\n                    <span className=\"text-xs text-muted-foreground\">\n                      Connected {formatDate(app.grantedAt)}\n                    </span>\n                  </div>\n                  <Button\n                    type=\"button\"\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive\"\n                    onClick={() => {\n                      setActionError(null)\n                      setRevokeTarget(app)\n                    }}\n                  >\n                    <Trash01 />\n                    Revoke\n                  </Button>\n                </div>\n              )\n            })}\n          </div>\n        )}\n      </CardContent>\n\n      <AlertDialog\n        open={revokeTarget !== null}\n        onOpenChange={(open) => {\n          if (!revoking && !open) setRevokeTarget(null)\n        }}\n      >\n        <AlertDialogContent>\n          <AlertDialogHeader>\n            <AlertDialogTitle>\n              Disconnect {revokeTarget?.clientName ?? revokeTarget?.clientId ?? \"this app\"}?\n            </AlertDialogTitle>\n            <AlertDialogDescription>\n              The app will no longer be able to act as you and its existing access will stop\n              working. You can authorise it again from the app at any time.\n            </AlertDialogDescription>\n          </AlertDialogHeader>\n          <AlertDialogFooter>\n            <AlertDialogCancel disabled={revoking}>Cancel</AlertDialogCancel>\n            <AlertDialogAction\n              variant=\"destructive\"\n              disabled={revoking}\n              onClick={(event) => {\n                event.preventDefault()\n                void handleRevoke()\n              }}\n            >\n              {revoking ? (\n                <>\n                  <LinkBroken01 />\n                  Disconnecting…\n                </>\n              ) : (\n                \"Disconnect app\"\n              )}\n            </AlertDialogAction>\n          </AlertDialogFooter>\n        </AlertDialogContent>\n      </AlertDialog>\n    </Card>\n  )\n}\n\nexport { ConnectedAppsCard }\n",
      "type": "registry:component",
      "target": "components/blocks/credentials/connected-apps-card.tsx"
    },
    {
      "path": "components/blocks/credentials/index.ts",
      "content": "/**\n * credentials — the account-credentials kit.\n *\n * {@link ApiKeysCard} is self-serve management of a user's central API keys\n * (personal access tokens): create with a shown-once secret, list with\n * expiry/last-used, revoke with confirm. {@link ConnectedAppsCard} lists the\n * user's OAuth consents (MCP clients, connected OIDC products) with revoke.\n *\n * Both are data-driven — wire the callbacks to your auth client (Better Auth\n * `apiKeyClient` / oauth-provider consent endpoints); the blocks own only the\n * interaction states.\n */\n\nexport {\n  ApiKeysCard,\n  type ApiKeySummary,\n  type CreateApiKeyInput,\n  type CreatedApiKey,\n} from \"./api-keys-card\"\nexport { ConnectedAppsCard, type ConnectedApp } from \"./connected-apps-card\"\n",
      "type": "registry:component",
      "target": "components/blocks/credentials/index.ts"
    }
  ],
  "type": "registry:block"
}