API key manager
A settings panel to create, reveal once, and revoke the org's API keys, wired to the api-keys router. Degrades to an explanatory notice for non-admins.
A self-contained API-key management panel over the apiKeys router. It lists the
org's keys (name, prefix, scopes, last used, status), creates keys through a
dialog that reveals the plaintext secret exactly once, and revokes a key
behind a confirm dialog. Every mutation invalidates the list so the table stays
current.
The apiKeys.* procedures are org-admin-gated. A non-admin viewer's list query
fails with FORBIDDEN; rather than crash or leak a raw error, the block hides the
whole management surface and shows an explanatory notice.
Requires: api-keys
Preview
| Name | Prefix | Scopes | Last used | Status | Actions |
|---|---|---|---|---|---|
| CI deploy key | sk_live_9f2a… | read, write | 7/16/2026 | Active | |
| Read-only analytics | sk_live_1b7c… | read | Never | Active |
"use client";import { useState } from "react";import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,} from "@/components/ui/alert-dialog";import { Button } from "@/components/ui/button";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";import { Input } from "@/components/ui/input";import { Label } from "@/components/ui/label";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table";import { trpc } from "@/lib/trpc";import { API_KEY_SCOPES } from "@/features/api-keys/lib/scopes";/** * A self-contained API-key management panel — a composed block over the * `apiKeys` router (`list` / `create` / `revoke`). Three pieces: a keys table * (name, prefix, scopes, last used, status), a create dialog whose plaintext * secret is **revealed exactly once** (the server never returns it again), and a * per-row revoke guarded by a confirm dialog. Every mutation invalidates * `apiKeys.list` so the table stays current. * * The `apiKeys.*` procedures are org-admin-gated server-side. Per PD-4 (role-gated * blocks degrade, never assume), a non-admin viewer's `list` query fails with * `FORBIDDEN`; the block hides the whole management affordance and shows an * explanatory notice instead of crashing or leaking a raw error. *//** Coarse access level a key is granted at creation → the scopes persisted. */type Access = (typeof API_KEY_SCOPES)[number];/** The client-facing key view the `apiKeys.list`/`create` procedures return. */type ApiKeyView = { id: string; name: string; keyPrefix: string; scopes: string[]; lastUsedAt: string | Date | null; expiresAt: string | Date | null; revokedAt: string | Date | null; createdAt: string | Date;};/** Human labels for the coarse v1 access levels (`read` ⇒ read-only, `write` ⇒ read+write). */const ACCESS_LABELS: Record<Access, string> = { read: "Read only", write: "Read & write",};/** Dates cross the tRPC boundary as strings; render a friendly fallback when absent. */function formatDate(value: string | Date | null): string { return value ? new Date(value).toLocaleDateString() : "Never";}/** A key's lifecycle status for the table. */function statusOf(key: ApiKeyView): string { if (key.revokedAt) return "Revoked"; if (key.expiresAt && new Date(key.expiresAt) < new Date()) return "Expired"; return "Active";}export function ApiKeyManager() { const utils = trpc.useUtils(); const keys = trpc.apiKeys.list.useQuery(); const revoke = trpc.apiKeys.revoke.useMutation({ onSuccess: () => utils.apiKeys.list.invalidate(), }); // PD-4 degrade: the list query is admin-gated, so a non-admin sees FORBIDDEN. // Hide the management surface and explain, rather than render a broken table. if (keys.error?.data?.code === "FORBIDDEN") { return ( <Card> <CardHeader> <CardTitle>API keys</CardTitle> <CardDescription> You need an admin role in this organization to manage API keys. </CardDescription> </CardHeader> </Card> ); } return ( <Card> <CardHeader className="flex flex-row items-start justify-between gap-4"> <div className="flex flex-col gap-1.5"> <CardTitle>API keys</CardTitle> <CardDescription> Programmatic credentials for the public API. A key's secret is shown once at creation — store it somewhere safe. </CardDescription> </div> <CreateKeyDialog /> </CardHeader> <CardContent className="flex flex-col gap-3 text-sm"> {keys.isPending && ( <p className="text-muted-foreground">Loading keys…</p> )} {keys.isError && ( <p role="alert" className="text-destructive"> Could not load API keys: {keys.error.message} </p> )} {keys.data && keys.data.length === 0 && ( <p className="text-muted-foreground"> No API keys yet. Create one to start using the public API. </p> )} {keys.data && keys.data.length > 0 && ( <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Prefix</TableHead> <TableHead>Scopes</TableHead> <TableHead>Last used</TableHead> <TableHead>Status</TableHead> <TableHead className="text-right">Actions</TableHead> </TableRow> </TableHeader> <TableBody> {keys.data.map((key) => ( <TableRow key={key.id}> <TableCell className="font-medium">{key.name}</TableCell> <TableCell className="font-mono text-xs"> {key.keyPrefix}… </TableCell> <TableCell>{key.scopes.join(", ")}</TableCell> <TableCell>{formatDate(key.lastUsedAt)}</TableCell> <TableCell>{statusOf(key)}</TableCell> <TableCell className="text-right"> <RevokeButton keyName={key.name} disabled={Boolean(key.revokedAt) || revoke.isPending} onConfirm={() => revoke.mutate({ id: key.id })} /> </TableCell> </TableRow> ))} </TableBody> </Table> )} </CardContent> </Card> );}/** Per-row revoke, guarded by a confirm dialog (revocation is irreversible). */function RevokeButton({ keyName, disabled, onConfirm,}: { keyName: string; disabled: boolean; onConfirm: () => void;}) { return ( <AlertDialog> <AlertDialogTrigger asChild> <Button variant="secondary" size="sm" disabled={disabled}> Revoke </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Revoke “{keyName}”?</AlertDialogTitle> <AlertDialogDescription> Any client using this key will immediately lose access. This cannot be undone. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogAction onClick={onConfirm}>Revoke key</AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> );}/** * The create dialog. On success the form is replaced by a reveal panel showing * the plaintext once with a copy affordance; closing the dialog clears the * revealed secret so it can never be read a second time. */function CreateKeyDialog() { const utils = trpc.useUtils(); const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [access, setAccess] = useState<Access>("read"); const [plaintext, setPlaintext] = useState<string | null>(null); const [copied, setCopied] = useState(false); const create = trpc.apiKeys.create.useMutation({ onSuccess: async (result) => { setPlaintext(result.plaintext); await utils.apiKeys.list.invalidate(); }, }); // Reset everything when the dialog is dismissed so a reopened dialog starts // fresh and the one-time secret is never shown again. function onOpenChange(next: boolean) { setOpen(next); if (!next) { setName(""); setAccess("read"); setPlaintext(null); setCopied(false); create.reset(); } } function onSubmit(event: React.FormEvent) { event.preventDefault(); if (name.trim().length === 0) return; // `write` implies `read` (D-054): a read-write key carries both scopes. const scopes: Access[] = access === "write" ? ["read", "write"] : ["read"]; create.mutate({ name: name.trim(), scopes }); } async function copyPlaintext() { if (!plaintext) return; try { await navigator.clipboard?.writeText(plaintext); setCopied(true); } catch { setCopied(false); } } return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogTrigger asChild> <Button size="sm">Create key</Button> </DialogTrigger> <DialogContent> {plaintext ? ( <> <DialogHeader> <DialogTitle>Copy your API key</DialogTitle> <DialogDescription> This is the only time the key will be shown. Store it securely — you won't be able to see it again. </DialogDescription> </DialogHeader> <div className="flex items-center gap-2"> <Input readOnly aria-label="API key" value={plaintext} /> <Button type="button" variant="secondary" onClick={copyPlaintext}> {copied ? "Copied" : "Copy"} </Button> </div> <DialogFooter> <Button type="button" onClick={() => onOpenChange(false)}> Done </Button> </DialogFooter> </> ) : ( <form onSubmit={onSubmit} className="flex flex-col gap-4"> <DialogHeader> <DialogTitle>Create API key</DialogTitle> <DialogDescription> Name the key and choose its access level. The secret is revealed once on the next screen. </DialogDescription> </DialogHeader> <div className="flex flex-col gap-2"> <Label htmlFor="api-key-name">Name</Label> <Input id="api-key-name" value={name} onChange={(event) => setName(event.target.value)} placeholder="CI deploy key" maxLength={128} autoComplete="off" /> </div> <div className="flex flex-col gap-2"> <Label htmlFor="api-key-access">Access</Label> <Select value={access} onValueChange={(value) => setAccess(value as Access)} > <SelectTrigger id="api-key-access"> <SelectValue /> </SelectTrigger> <SelectContent> {API_KEY_SCOPES.map((scope) => ( <SelectItem key={scope} value={scope}> {ACCESS_LABELS[scope]} </SelectItem> ))} </SelectContent> </Select> </div> {create.isError && ( <p role="alert" className="text-sm text-destructive"> {create.error.message || "Could not create that key."} </p> )} <DialogFooter> <Button type="submit" disabled={create.isPending || name.trim().length === 0} > {create.isPending ? "Creating…" : "Create key"} </Button> </DialogFooter> </form> )} </DialogContent> </Dialog> );}Offline preview
The docs site has no backend, so the preview seeds two representative keys to
show the populated admin table. In an app the data comes from the apiKeys
router; a non-admin instead sees the "you need an admin role" notice, and the
created secret is revealed once.
Install
npx create-saas-starter add api-key-managerRun it from your app's root. add only writes new files — wiring is up to you:
import { ApiKeyManager } from "@/components/blocks/api-key-manager/api-key-manager";Active sessions card
A settings card listing the account's live sessions with a friendly device label and per-device revoke, wired to the user router.
File dropzone
A drag-and-drop upload zone driving the presigned files flow (createUpload → PUT → confirmUpload), with a recent-files list. Shows a storage-limit hint when flags is present.