Blocksblock
API keys
List, create (key shown once, optional permissions), and revoke API keys.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/api-keysnpm dependencies
- lucide-react@^1.51.0
Installs these registry items
Usage
"use client"
import * as React from "react"
import { ApiKeys, type ApiKey } from "@/components/api-keys"
import { minutesAgo } from "@/registry/particular/examples/demo-data"
function wait(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
function randomSecret() {
const bytes = crypto.getRandomValues(new Uint8Array(24))
return `pt_live_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`
}
export function Demo() {
const [keys, setKeys] = React.useState<ApiKey[]>([
{ id: "key_1", name: "GitHub Actions", prefix: "pt_live_8f2a", createdAt: minutesAgo(60 * 24 * 40), lastUsedAt: minutesAgo(12), permissions: { deployments: ["read", "write"] } },
{ id: "key_2", name: "Local development", prefix: "pt_live_c91d", createdAt: minutesAgo(60 * 24 * 3), lastUsedAt: null, permissions: { projects: ["read"] } },
])
return (
<div className="mx-auto w-full max-w-3xl p-6">
<ApiKeys
keys={keys}
permissionOptions={[
{ resource: "projects", action: "read", label: "Read projects", description: "List projects and their settings." },
{ resource: "deployments", action: "read", label: "Read deployments" },
{ resource: "deployments", action: "write", label: "Create deployments", description: "Trigger and cancel builds." },
]}
onCreate={async (name, permissions) => {
await wait(700)
if (name.toLowerCase() === "fail") throw new Error("Key limit reached for this team.")
const key = randomSecret()
setKeys((current) => [
{ id: `key_${current.length + 1}`, name, prefix: key.slice(0, 12), createdAt: new Date(), lastUsedAt: null, permissions },
...current,
])
return { key }
}}
onRevoke={async (id) => {
await wait(700)
setKeys((current) => current.filter((key) => key.id !== id))
}}
/>
</div>
)
}Source
"use client"
import * as React from "react"
import { KeyRoundIcon, PlusIcon, TriangleAlertIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Field, FieldContent, FieldDescription, FieldError, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { RelativeTime } from "@/components/ui/relative-time"
import { SecretInput } from "@/components/ui/secret-input"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
export type ApiKey = {
id: string
name: string
/** Non-secret identifying prefix, e.g. `pt_live_8f2a`. */
prefix: string
createdAt: Date | string
lastUsedAt: Date | string | null
/** Resource → actions the key may perform, e.g. `{ projects: ["read"] }`. */
permissions?: Record<string, string[]> | null
}
/** One grantable permission, shown as a checkbox when creating a key. */
export type ApiKeyPermissionOption = {
resource: string
action: string
label: string
description?: string
}
export type ApiKeysProps = {
keys: ApiKey[]
/** Create a key and return the full secret. It is shown once and never again. */
onCreate: (name: string, permissions: Record<string, string[]>) => Promise<{ key: string }>
onRevoke: (id: string) => Promise<void>
/** When set, creating a key requires choosing at least one of these permissions. */
permissionOptions?: ApiKeyPermissionOption[]
}
function formatPermissions(permissions: Record<string, string[]>) {
return Object.entries(permissions).flatMap(([resource, actions]) => actions.map((action) => `${resource}:${action}`))
}
function messageOf(error: unknown) {
return error instanceof Error ? error.message : "Something went wrong. Try again."
}
function CreateKeyDialog({
open,
onOpenChange,
onCreate,
permissionOptions,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreate: ApiKeysProps["onCreate"]
permissionOptions: ApiKeyPermissionOption[] | undefined
}) {
const [secret, setSecret] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const close = (next: boolean) => {
if (next) return onOpenChange(true)
if (pending) return
setSecret(null)
setError(null)
onOpenChange(false)
}
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const name = String(form.get("name") ?? "").trim()
if (!name) return setError("Give the key a name so you can recognize it later.")
const grouped = new Map<string, string[]>()
for (const value of form.getAll("permission")) {
const option = permissionOptions?.[Number(value)]
if (!option) throw new Error(`Unknown permission option ${String(value)}`)
grouped.set(option.resource, [...(grouped.get(option.resource) ?? []), option.action])
}
const permissions = Object.fromEntries(grouped)
if (permissionOptions?.length && grouped.size === 0) return setError("Choose at least one permission.")
setPending(true)
setError(null)
try {
setSecret((await onCreate(name, permissions)).key)
} catch (caught) {
setError(messageOf(caught))
} finally {
setPending(false)
}
}
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="sm:max-w-md">
{secret ? (
<>
<DialogHeader>
<DialogTitle>Save your API key</DialogTitle>
<DialogDescription>Copy it now. For security, you will not be able to see it again.</DialogDescription>
</DialogHeader>
<SecretInput value={secret} defaultRevealed aria-label="New API key" />
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle>Treat this like a password</AlertTitle>
<AlertDescription>Store it in a secret manager or environment variable, never in source control.</AlertDescription>
</Alert>
<DialogFooter>
<Button onClick={() => close(false)}>Done</Button>
</DialogFooter>
</>
) : (
<form onSubmit={submit} className="grid gap-4">
<DialogHeader>
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>
{permissionOptions?.length ? "The key can only do what you allow below." : "Keys have full access to this team's resources."}
</DialogDescription>
</DialogHeader>
<Field data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="api-key-name">Name</FieldLabel>
<Input id="api-key-name" name="name" placeholder="CI deploys" autoComplete="off" autoFocus aria-invalid={error ? true : undefined} />
{error ? <FieldError>{error}</FieldError> : <FieldDescription>Where the key is used, e.g. "GitHub Actions".</FieldDescription>}
</Field>
{permissionOptions && permissionOptions.length > 0 && (
<FieldSet>
<FieldLegend variant="label">Permissions</FieldLegend>
{permissionOptions.map((option, index) => {
const id = `permission-${index}`
return (
<Field key={id} orientation="horizontal">
<Checkbox id={id} name="permission" value={String(index)} />
<FieldContent>
<FieldLabel htmlFor={id} className="font-normal">
{option.label}
</FieldLabel>
{option.description && <FieldDescription>{option.description}</FieldDescription>}
</FieldContent>
</Field>
)
})}
</FieldSet>
)}
<DialogFooter>
<Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Spinner data-icon="inline-start" />}
Create key
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}
function RevokeKeyDialog({
apiKey,
onOpenChange,
onRevoke,
}: {
apiKey: ApiKey | null
onOpenChange: (open: boolean) => void
onRevoke: ApiKeysProps["onRevoke"]
}) {
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | null>(null)
const revoke = async () => {
if (!apiKey) return
setPending(true)
setError(null)
try {
await onRevoke(apiKey.id)
onOpenChange(false)
} catch (caught) {
setError(messageOf(caught))
} finally {
setPending(false)
}
}
return (
<AlertDialog
open={apiKey !== null}
onOpenChange={(open) => {
if (pending) return
if (!open) setError(null)
onOpenChange(open)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke {apiKey?.name}?</AlertDialogTitle>
<AlertDialogDescription>
Requests using <span className="font-mono">{apiKey?.prefix}…</span> will fail immediately. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={pending}
onClick={(event) => {
event.preventDefault()
void revoke()
}}
>
{pending && <Spinner data-icon="inline-start" />}
Revoke key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
function ApiKeys({ keys, onCreate, onRevoke, permissionOptions }: ApiKeysProps) {
const showPermissions = permissionOptions !== undefined || keys.some((key) => key.permissions)
const [createOpen, setCreateOpen] = React.useState(false)
const [revoking, setRevoking] = React.useState<ApiKey | null>(null)
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-heading text-base font-semibold">API keys</h2>
<p className="text-sm text-muted-foreground">Authenticate requests to the Particular.Tech API.</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon data-icon="inline-start" />
Create key
</Button>
</div>
{keys.length === 0 ? (
<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">
<KeyRoundIcon />
</EmptyMedia>
<EmptyTitle>No API keys yet</EmptyTitle>
<EmptyDescription>Create a key to call the API from scripts, CI, or your own services.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => setCreateOpen(true)}>Create your first key</Button>
</EmptyContent>
</Empty>
) : (
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Key</TableHead>
{showPermissions && <TableHead className="hidden md:table-cell">Permissions</TableHead>}
<TableHead className="hidden sm:table-cell">Created</TableHead>
<TableHead className="hidden sm:table-cell">Last used</TableHead>
<TableHead className="w-0">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((apiKey) => (
<TableRow key={apiKey.id}>
<TableCell className="font-medium">{apiKey.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{apiKey.prefix}…</TableCell>
{showPermissions && (
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{apiKey.permissions
? formatPermissions(apiKey.permissions).map((permission) => (
<Badge key={permission} variant="secondary" className="font-mono">
{permission}
</Badge>
))
: <span className="text-muted-foreground">All</span>}
</div>
</TableCell>
)}
<TableCell className="hidden text-muted-foreground sm:table-cell">
<RelativeTime date={apiKey.createdAt} />
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">
{apiKey.lastUsedAt ? <RelativeTime date={apiKey.lastUsedAt} /> : "Never"}
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setRevoking(apiKey)}>
Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<CreateKeyDialog open={createOpen} onOpenChange={setCreateOpen} onCreate={onCreate} permissionOptions={permissionOptions} />
<RevokeKeyDialog apiKey={revoking} onOpenChange={(open) => !open && setRevoking(null)} onRevoke={onRevoke} />
</div>
)
}
export { ApiKeys }