zazzy
Blocks

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

API keys
Programmatic credentials for the public API. A key's secret is shown once at creation — store it somewhere safe.
NamePrefixScopesLast usedStatusActions
CI deploy keysk_live_9f2aread, write7/16/2026Active
Read-only analyticssk_live_1b7creadNeverActive
"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&apos;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&apos;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-manager

Run 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";

On this page