feat: Restyle the API keys page onto the shared list patterns

The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.

The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.

Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.

In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
This commit is contained in:
2026-08-13 08:59:06 +00:00
parent 689d0e1d5b
commit 18495dba68
+184 -88
View File
@@ -4,7 +4,24 @@ 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 {
AsyncBoundary,
Badge,
Button,
Card,
ConfirmDialog,
EmptyState,
Modal,
Table,
TableSkeleton,
Tbody,
Td,
Th,
Thead,
Tr,
friendlyMessage,
useToast,
} from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -34,6 +51,43 @@ function rolesAtOrBelow(role: Role): Role[] {
return idx === -1 ? ROLES : ROLES.slice(idx);
}
/**
* Collapses ["servers:read","servers:write","keys:read"] into one chip per
* resource carrying its access. Sixteen scopes rendered as sixteen badges make
* the row taller than everything around it and still have to be read one at a
* time; the resource is what a person scans for, and r/w is the qualifier.
*/
function summariseScopes(scopes: string[]): { resource: string; access: string }[] {
const byResource = new Map<string, { read: boolean; write: boolean }>();
for (const scope of scopes) {
const [resource, action] = scope.split(":");
const entry = byResource.get(resource) ?? { read: false, write: false };
if (action === "read") entry.read = true;
if (action === "write") entry.write = true;
byResource.set(resource, entry);
}
return Array.from(byResource, ([resource, { read, write }]) => ({
resource,
// write implies read on the server, so a token holding only :write is
// still shown as rw rather than pretending it cannot read.
access: write ? "rw" : read ? "r" : "",
}));
}
function ScopeChips({ scopes }: { scopes: string[] }) {
if (scopes.length === 0) return <span className="text-text-secondary"></span>;
return (
<div className="flex flex-wrap gap-1">
{summariseScopes(scopes).map(({ resource, access }) => (
<Badge key={resource} variant="neutral">
{resource}
<span className="ml-1 font-mono text-[0.65rem] uppercase tracking-[0.08em] opacity-70">{access}</span>
</Badge>
))}
</div>
);
}
/** 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. */
@@ -168,98 +222,124 @@ export function ApiKeysPanel() {
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
const count = tokens?.length ?? 0;
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.
{count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}
</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>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New key
</Button>
</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" />
{/* Owner and admin can see everyone's keys, so the scope of the list
is a filter rather than a preference — the same pill treatment
the vulnerabilities page uses for its state filter, so a person
who has learned one has learned both. */}
{isAdmin && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{[
{ label: "My keys", all: false },
{ label: "All keys", all: true },
].map((option) => (
<button
key={option.label}
type="button"
onClick={() => setShowAll(option.all)}
aria-pressed={showAll === option.all}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
showAll === option.all ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
{option.label}
</button>
))}
</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 padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
isEmpty={count === 0}
empty={
<EmptyState
title={showAll ? "No API keys in this instance." : "You have no API keys."}
description={
showAll
? "Nobody has created a key yet. Keys let scripts and CI call the REST API without a browser session."
: "Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never exceeds your own role."
}
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
}
action={{ label: "Create your first key", onClick: () => setCreateOpen(true) }}
/>
}
>
<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">
<ScopeChips scopes={t.scopes} />
</Td>
<Td label="Last used" className="text-text-secondary">
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : <span className="text-text-secondary/70">Never used</span>}
</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>
</AsyncBoundary>
</Card>
<ConfirmDialog
@@ -284,17 +364,33 @@ export function ApiKeysPanel() {
<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 className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
This is the only time <span className="font-semibold">{result.record.name}</span> is shown. Copy it now Vantage stores only a
hash and cannot show it again.
</div>
<code className="block overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-text-secondary">Role</dt>
<dd className="text-text-primary">{result.record.role}</dd>
<dt className="text-text-secondary">Scopes</dt>
<dd>
<ScopeChips scopes={result.record.scopes} />
</dd>
<dt className="text-text-secondary">Expires</dt>
<dd className="text-text-primary">
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
</dd>
</dl>
{/* Copy is the primary action, not Done: the value is
unrecoverable once this closes, so the button that
saves it should be the one under the pointer. */}
<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}>
<Button type="button" variant="ghost" onClick={closeCreate}>
Done
</Button>
<Button type="button" variant="primary" onClick={copyToken}>
{copied ? "Copied" : "Copy key"}
</Button>
</div>
</div>
) : (