feat: Give API keys their own page and group the sidebar

The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.

The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.

The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
This commit is contained in:
2026-08-13 08:54:42 +00:00
parent 95527b3956
commit 689d0e1d5b
6 changed files with 201 additions and 88 deletions
+391
View File
@@ -0,0 +1,391 @@
"use client";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, Card, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
const ROLES: Role[] = ["owner", "admin", "member"];
const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
{ label: "30 days", days: 30 },
{ label: "60 days", days: 60 },
{ label: "90 days", days: 90 },
{ label: "365 days", days: 365 },
{ label: "Never", days: null },
];
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** The token a pending revoke refers to, carried so the dialog and the
* confirmation message name a token rather than a token_id. */
type PendingRevoke = { id: string; name: string };
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
function rolesAtOrBelow(role: Role): Role[] {
const idx = ROLES.indexOf(role);
return idx === -1 ? ROLES : ROLES.slice(idx);
}
/** Renders a token's expiry, plus a policy note when the cap has tightened
* since the token was issued. The policy is not applied retroactively, so an
* outside-policy token is a prompt to rotate, not a failure of any kind. */
function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
const outsidePolicy = capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > Date.now() + capDays * 24 * 60 * 60 * 1000);
if (!token.expires_at) {
return (
<div>
<span className="text-text-secondary"> never</span>
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy rotate when convenient</p>}
</div>
);
}
const expiresAt = new Date(token.expires_at);
const expired = expiresAt.getTime() <= Date.now();
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
return (
<div>
<span className={expired ? "text-danger" : soon ? "text-warning" : "text-text-secondary"}>
{expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()}
</span>
{outsidePolicy && <p className="mt-0.5 text-xs text-warning">outside the current policy rotate when convenient</p>}
</div>
);
}
/**
* The whole API Keys page body, header included.
*
* It is a page rather than a card on /settings because any member may mint and
* revoke their own keys — the API has never required owner or admin for that —
* while /settings is owner|admin throughout. The instance-wide lifetime cap
* stays on /settings, being policy rather than one person's credentials.
*/
export function ApiKeysPanel() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
const [showAll, setShowAll] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [revoking, setRevoking] = useState<PendingRevoke | null>(null);
const [name, setName] = useState("");
const [role, setRole] = useState<Role>("member");
const [scopes, setScopes] = useState<string[]>([]);
const [expiryDays, setExpiryDays] = useState<number | null>(30);
const [result, setResult] = useState<{ token: string; record: ApiToken } | null>(null);
const [copied, setCopied] = useState(false);
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings, enabled: isAdmin });
const capDays = settings?.api_token_max_days ?? 0;
const {
data: tokensData,
isLoading,
error,
} = useQuery({ queryKey: ["api-tokens", showAll], queryFn: () => api.listApiTokens(showAll) });
const tokens = tokensData?.tokens;
const { data: scopesData } = useQuery({ queryKey: ["token-scopes"], queryFn: api.listTokenScopes, enabled: createOpen });
const availableScopes = scopesData?.scopes ?? [];
const resources = Array.from(new Set(availableScopes.map((s) => s.split(":")[0])));
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["api-tokens"] });
function resetForm() {
setName("");
setRole("member");
setScopes([]);
setExpiryDays(30);
setResult(null);
setCopied(false);
}
// Once the policy is known, default to the shortest option the policy still
// allows rather than a value the submit is about to be refused for.
useEffect(() => {
if (!createOpen) return;
const valid = EXPIRY_OPTIONS.find((o) => !(capDays > 0 && (o.days === null || o.days > capDays)));
if (valid) setExpiryDays(valid.days);
}, [createOpen, capDays]);
const {
mutate: createToken,
isPending: creating,
error: createError,
reset: resetCreateError,
} = useMutation({
mutationFn: () => api.createApiToken({ name, role, scopes, expires_in_days: expiryDays ?? undefined }),
onSuccess: (res) => {
setResult(res);
},
});
const {
mutate: revokeToken,
isPending: isRevoking,
error: revokeError,
reset: resetRevoke,
} = useMutation({
mutationFn: (t: PendingRevoke) => api.revokeApiToken(t.id),
onSuccess: (_data, t) => {
invalidate();
toast.success(`Revoked ${t.name}.`);
setRevoking(null);
},
});
function toggleScope(s: string) {
setScopes((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
}
function closeCreate() {
// The plaintext is gone once this closes, so only invalidate having
// shown it — closing before a result exists is a plain cancel.
if (result) invalidate();
setCreateOpen(false);
resetCreateError();
resetForm();
}
async function copyToken() {
if (!result) return;
await navigator.clipboard.writeText(result.token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
<p className="mt-1 text-sm text-text-secondary">
Scoped, personal keys for scripts and CI to call the REST API without a browser session. A key never exceeds your own role.
</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && (
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={showAll}
onChange={(e) => setShowAll(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
All keys
</label>
)}
<Button variant="primary" onClick={() => setCreateOpen(true)}>
New key
</Button>
</div>
</div>
<Card className="p-0">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="p-6 text-sm text-danger">{friendlyMessage(error)}</p>
) : !tokens || tokens.length === 0 ? (
<p className="p-6 text-sm text-text-secondary">
No API keys yet. Create one to call the REST API from a script or a CI job.
</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
{showAll && <Th>Owner</Th>}
<Th>Role</Th>
<Th>Scopes</Th>
<Th>Last used</Th>
<Th>Expires</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{tokens.map((t) => (
<Tr key={t.token_id}>
<Td label="Name">
<span className="font-medium text-text-primary">{t.name}</span>
<div className="font-mono text-xs text-text-secondary">{t.hint}</div>
</Td>
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
<Td label="Role">
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
</Td>
<Td label="Scopes">
<div className="flex flex-wrap gap-1">
{t.scopes.map((s) => (
<Badge key={s} variant="neutral">
{s}
</Badge>
))}
</div>
</Td>
<Td label="Last used" className="text-text-secondary">
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
</Td>
<Td label="Expires">
<ExpiryCell token={t} capDays={capDays} />
</Td>
<Td label="Actions" className="text-right">
<Button
variant="ghost"
size="sm"
className="text-danger hover:text-danger"
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
<ConfirmDialog
open={revoking !== null}
title="Revoke key"
confirmLabel="Revoke key"
loading={isRevoking}
error={revokeError ? friendlyMessage(revokeError) : null}
onClose={() => {
resetRevoke();
setRevoking(null);
}}
onConfirm={() => revoking && revokeToken(revoking)}
body={
<p>
<span className="text-text-primary">{revoking?.name}</span> stops authenticating immediately. Any script or CI job using it
will start failing on its next call.
</p>
}
/>
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
{result ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This is the only time this key will be shown. Store it now.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={copyToken}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button type="button" variant="primary" onClick={closeCreate}>
Done
</Button>
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
createToken();
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
const writeScope = `${r}:write`;
return (
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
<span className="text-sm capitalize text-text-primary">{r}</span>
<div className="flex gap-3">
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(readScope)}
onChange={() => toggleScope(readScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(writeScope)}
onChange={() => toggleScope(writeScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
write
</label>
</div>
</div>
);
})}
</div>
</Field>
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
className={inputClass}
>
{EXPIRY_OPTIONS.map((o) => {
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
return (
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
{o.label}
</option>
);
})}
</select>
</Field>
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeCreate}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create key
</Button>
</div>
</form>
)}
</Modal>
</div>
);
}