Particular.Tech
Blocksblock

API keys

List, create (key shown once, optional permissions), and revoke API keys.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/api-keys
npm dependencies
  • lucide-react@^1.51.0

Usage

api-keys-demo.tsx
"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

blocks/api-keys/api-keys.tsx
"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. &quot;GitHub Actions&quot;.</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 }