/** * API Tokens settings page * * Allows admins to list, create, and revoke Personal Access Tokens. */ import { Button, Checkbox, Input, Loader, Select } from "@cloudflare/kumo"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { Copy, Eye, EyeSlash, Key, Plus, Trash, WarningCircle } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { fetchApiTokens, createApiToken, revokeApiToken, API_TOKEN_SCOPES, type ApiTokenCreateResult, type ApiTokenScopeValue, } from "../../lib/api/api-tokens.js"; import { getMutationError } from "../DialogError.js"; import { BackToSettingsLink } from "./BackToSettingsLink.js"; // ============================================================================= // Expiry options // ============================================================================= const EXPIRY_OPTIONS = [ { value: "none", label: msg`No expiry` }, { value: "7d", label: msg`7 days` }, { value: "30d", label: msg`30 days` }, { value: "90d", label: msg`90 days` }, { value: "365d", label: msg`1 year` }, ] as const; const API_TOKEN_SCOPE_VALUES: { scope: ApiTokenScopeValue; label: MessageDescriptor; description: MessageDescriptor; }[] = [ { scope: API_TOKEN_SCOPES.ContentRead, label: msg`Content Read`, description: msg`Read content entries`, }, { scope: API_TOKEN_SCOPES.ContentWrite, label: msg`Content Write`, description: msg`Create, update, delete content`, }, { scope: API_TOKEN_SCOPES.MediaRead, label: msg`Media Read`, description: msg`Read media files`, }, { scope: API_TOKEN_SCOPES.MediaWrite, label: msg`Media Write`, description: msg`Upload and delete media`, }, { scope: API_TOKEN_SCOPES.SchemaRead, label: msg`Schema Read`, description: msg`Read collection schemas`, }, { scope: API_TOKEN_SCOPES.SchemaWrite, label: msg`Schema Write`, description: msg`Modify collection schemas`, }, { scope: API_TOKEN_SCOPES.TaxonomiesManage, label: msg`Taxonomies Manage`, description: msg`Create, update, and delete taxonomy terms`, }, { scope: API_TOKEN_SCOPES.MenusManage, label: msg`Menus Manage`, description: msg`Create, update, and delete navigation menus`, }, { scope: API_TOKEN_SCOPES.SettingsRead, label: msg`Settings Read`, description: msg`Read site settings`, }, { scope: API_TOKEN_SCOPES.SettingsManage, label: msg`Settings Manage`, description: msg`Update site settings`, }, { scope: API_TOKEN_SCOPES.Admin, label: msg`Admin`, description: msg`Full admin access`, }, ]; /** Wire scopes shown on the create-token form (contract-tested vs `API_TOKEN_SCOPES` and `@emdash-cms/auth`). */ export const API_TOKEN_SCOPE_FORM_SCOPES: readonly ApiTokenScopeValue[] = API_TOKEN_SCOPE_VALUES.map((row) => row.scope); function computeExpiryDate(option: string): string | undefined { if (option === "none") return undefined; const days = parseInt(option, 10); if (Number.isNaN(days)) return undefined; const date = new Date(); date.setDate(date.getDate() + days); return date.toISOString(); } // ============================================================================= // Main component // ============================================================================= export function ApiTokenSettings() { const { t } = useLingui(); const queryClient = useQueryClient(); const [showCreateForm, setShowCreateForm] = React.useState(false); const [newToken, setNewToken] = React.useState(null); const [tokenVisible, setTokenVisible] = React.useState(false); const [copied, setCopied] = React.useState(false); const [revokeConfirmId, setRevokeConfirmId] = React.useState(null); // Queries const { data: tokens, isLoading } = useQuery({ queryKey: ["api-tokens"], queryFn: fetchApiTokens, }); // Create mutation const createMutation = useMutation({ mutationFn: createApiToken, onSuccess: (result) => { setNewToken(result); setShowCreateForm(false); setTokenVisible(false); setCopied(false); void queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); }, }); // Revoke mutation const revokeMutation = useMutation({ mutationFn: revokeApiToken, onSuccess: () => { setRevokeConfirmId(null); void queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); }, }); // Clean up copy feedback timeout on unmount const copyTimeoutRef = React.useRef | undefined>(undefined); React.useEffect(() => { return () => { if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); }; }, []); const handleCopyToken = async () => { if (!newToken) return; try { await navigator.clipboard.writeText(newToken.token); setCopied(true); copyTimeoutRef.current = setTimeout(setCopied, 2000, false); } catch { // Clipboard API can fail in insecure contexts or when denied } }; const expirySelectItems = React.useMemo( () => Object.fromEntries(EXPIRY_OPTIONS.map((o) => [o.value, t(o.label)])), [t], ); return (
{/* Header */}

{t(msg`API Tokens`)}

{t(msg`Create personal access tokens for programmatic API access`)}

{/* New token banner */} {newToken && (

{t(msg`Token created: ${newToken.info.name}`)}

{t(msg`Copy this token now — it won't be shown again.`)}

{tokenVisible ? newToken.token : "••••••••••••••••••••••••••••"}
{copied && (

{t(msg`Copied to clipboard`)}

)}
)} {/* Create form */} {showCreateForm ? ( createMutation.mutate({ name: input.name, scopes: input.scopes, expiresAt: input.expiresAt, }) } onCancel={() => setShowCreateForm(false)} /> ) : ( )} {/* Token list */}
{isLoading ? (
) : !tokens || tokens.length === 0 ? (
{t(msg`No API tokens yet. Create one to get started.`)}
) : (
{tokens.map((token) => (
{token.name} {token.prefix}...
{t(msg`Scopes: ${token.scopes.join(", ")}`)} {token.expiresAt && ( {t(msg`Expires ${new Date(token.expiresAt).toLocaleDateString()}`)} )} {token.lastUsedAt && ( {t(msg`Last used ${new Date(token.lastUsedAt).toLocaleDateString()}`)} )}
{t(msg`Created ${new Date(token.createdAt).toLocaleDateString()}`)}
{revokeConfirmId === token.id ? (
{revokeMutation.error && ( {getMutationError(revokeMutation.error)} )} {t(msg`Revoke?`)}
) : ( )}
))}
)}
); } // ============================================================================= // Create token form // ============================================================================= interface CreateTokenFormProps { expirySelectItems: Record; isCreating: boolean; error: string | null; onSubmit: (input: { name: string; scopes: string[]; expiresAt?: string }) => void; onCancel: () => void; } function CreateTokenForm({ expirySelectItems, isCreating, error, onSubmit, onCancel, }: CreateTokenFormProps) { const { t } = useLingui(); const [name, setName] = React.useState(""); const [selectedScopes, setSelectedScopes] = React.useState>(new Set()); const [expiry, setExpiry] = React.useState("30d"); const toggleScope = (scope: string) => { setSelectedScopes((prev) => { const next = new Set(prev); if (next.has(scope)) { next.delete(scope); } else { next.add(scope); } return next; }); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit({ name: name.trim(), scopes: [...selectedScopes], expiresAt: computeExpiryDate(expiry), }); }; const isValid = name.trim().length > 0 && selectedScopes.size > 0; return (

{t(msg`Create New Token`)}

{error && (
{error}
)}
setName(e.target.value)} placeholder={t(msg`e.g., CI/CD Pipeline`)} required autoFocus />
{t(msg`Scopes`)}
{API_TOKEN_SCOPE_VALUES.map(({ scope, label, description }) => { return ( ); })}
); }