feat: redesign the api key list as a ledger with lifetime bars

This commit is contained in:
2026-09-08 14:07:56 +00:00
parent a0b5565a63
commit 67ac029354
4 changed files with 153 additions and 81 deletions
+2 -3
View File
@@ -10,11 +10,10 @@ import {
Card,
ConfirmDialog,
EmptyState,
TableSkeleton,
friendlyMessage,
useToast,
} from "@/components/ui";
import { KeyLedger } from "./KeyLedger";
import { KeyLedger, LedgerSkeleton } from "./KeyLedger";
import { CreateKeyDialog, EXPIRY_OPTIONS } from "./CreateKeyDialog";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -181,7 +180,7 @@ export function ApiKeysPanel() {
<AsyncBoundary
isLoading={isLoading}
error={error}
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
skeleton={<LedgerSkeleton />}
isEmpty={count === 0}
empty={
<EmptyState
+87 -47
View File
@@ -1,7 +1,7 @@
import type { ApiToken, Role } from "@/lib/api";
import { Badge, Button, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { Badge, Button } from "@/components/ui";
import { ScopeChips } from "./ScopeChips";
import { ExpiryCell } from "./LifetimeBar";
import { LifetimeBar } from "./LifetimeBar";
export function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
@@ -9,6 +9,20 @@ export function roleVariant(role: Role) {
return "neutral" as const;
}
/*
* A grid rather than the shared <Table>: the identity column stacks four
* things — name, hint, holder, role — and Td assumes one value per cell.
*
* Below lg the grid collapses to a stacked record and each cell grows its own
* label from data-label. A date sitting under a chip list with no headings is
* unreadable once the columns are gone, and the header row cannot follow the
* cells down.
*/
const COLUMNS = "lg:grid-cols-[minmax(220px,1.5fr)_minmax(180px,1.3fr)_minmax(150px,1fr)_150px_auto]";
const LABEL =
"before:mb-1.5 before:block before:font-mono before:text-[0.65rem] before:uppercase before:tracking-[0.08em] before:text-text-tertiary before:content-[attr(data-label)] lg:before:hidden";
export function KeyLedger({
tokens,
showAll,
@@ -21,51 +35,77 @@ export function KeyLedger({
onRevoke: (t: { id: string; name: string }) => void;
}) {
return (
<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">
<div>
<div
className={`hidden bg-surface-2 px-4 py-2 font-mono text-[0.65rem] uppercase tracking-[0.08em] text-text-tertiary lg:grid lg:gap-5 ${COLUMNS}`}
>
<span>Key {showAll && "/ holder"}</span>
<span>Scopes</span>
<span>Lifetime</span>
<span>Last call</span>
<span className="sr-only">Actions</span>
</div>
{tokens.map((t) => (
<div
key={t.token_id}
className={`relative grid gap-3 border-t border-border-soft px-4 py-4 transition-colors hover:bg-surface-2 lg:items-center lg:gap-5 ${COLUMNS}`}
>
<div className="flex min-w-0 flex-col gap-1 pr-24 lg:pr-0">
<span className="font-medium text-text-primary">{t.name}</span>
<span className="font-mono text-xs text-text-tertiary">{t.hint}</span>
{/* The holder joins the identity rather than claiming a
fifth column, so All keys changes what a record says
instead of how the page is laid out. */}
<span className="flex flex-wrap items-center gap-1.5 text-xs text-text-tertiary">
{showAll && <span>{t.user_email ?? t.user_id}</span>}
<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={() => onRevoke({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</span>
</div>
<div data-label="scopes" className={LABEL}>
<ScopeChips scopes={t.scopes} wrap={false} />
</div>
<div data-label="lifetime" className={LABEL}>
<LifetimeBar token={t} capDays={capDays} />
</div>
<div data-label="last call" className={LABEL}>
<span className={`font-mono text-xs tabular-nums ${t.last_used_at ? "text-text-secondary" : "text-text-tertiary"}`}>
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never used"}
</span>
</div>
<div className="absolute right-3 top-3 lg:static lg:text-right">
<Button
variant="ghost"
size="sm"
className="border border-border text-danger hover:text-danger lg:border-transparent"
onClick={() => onRevoke({ id: t.token_id, name: t.name })}
>
Revoke<span className="sr-only"> {t.name}</span>
</Button>
</div>
</div>
))}
</div>
);
}
/** A ledger-shaped loading state. TableSkeleton draws a table, and the shape
* flipping under the reader on the first paint reads as a layout bug. */
export function LedgerSkeleton() {
return (
<div>
{[0, 1, 2].map((i) => (
<div key={i} className="grid gap-3 border-t border-border-soft px-4 py-5 lg:gap-5 lg:grid-cols-4">
<div className="h-4 w-40 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-32 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-36 animate-pulse rounded bg-surface-2" />
<div className="h-4 w-28 animate-pulse rounded bg-surface-2" />
</div>
))}
</div>
);
}
+31 -22
View File
@@ -1,32 +1,41 @@
import type { ApiToken } from "@/lib/api";
import { keyLifetime, type LifetimeState } from "@/lib/keyLifetime";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/*
* A key's expiry drawn as the share of its issued life still to run.
*
* A column of dates answers "when" but not "which of these needs me first",
* which is the only question the list is scanned for. The bar answers it at a
* glance and the label underneath still says the date, because state never
* reads by colour alone here.
*/
/** 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. */
export 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);
const FILL: Record<LifetimeState, string> = {
healthy: "bg-success",
soon: "bg-warning",
expired: "bg-danger",
eternal: "bg-text-tertiary",
};
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 TEXT: Record<LifetimeState, string> = {
healthy: "text-text-secondary",
soon: "text-warning",
expired: "text-danger",
eternal: "text-text-tertiary",
};
const expiresAt = new Date(token.expires_at);
const expired = expiresAt.getTime() <= Date.now();
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
export function LifetimeBar({ token, capDays }: { token: ApiToken; capDays: number }) {
const { state, remainingPct, label, outsidePolicy } = keyLifetime(token, capDays);
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 className="flex flex-col gap-1.5">
{/* The bar is the primary signal in the row, so it carries the same
text as the label rather than reading as decoration. */}
<div className="h-1 overflow-hidden rounded-full bg-border-soft" role="img" aria-label={label}>
<div className={`h-full ${FILL[state]}`} style={{ width: `${remainingPct}%` }} />
</div>
<span className={`font-mono text-xs tabular-nums ${TEXT[state]}`}>{label}</span>
{outsidePolicy && <p className="text-xs text-warning">Outside the current policy rotate when convenient.</p>}
</div>
);
}
+33 -9
View File
@@ -1,5 +1,3 @@
import { Badge } from "@/components/ui";
/**
* Collapses ["servers:read","servers:write","keys:read"] into one chip per
* resource carrying its access. Sixteen scopes rendered as sixteen badges make
@@ -23,15 +21,41 @@ export function summariseScopes(scopes: string[]): { resource: string; access: s
}));
}
export function ScopeChips({ scopes }: { scopes: string[] }) {
if (scopes.length === 0) return <span className="text-text-secondary"></span>;
/**
* The chip splits in two — resource, then a tinted access half — so the read
* and write halves of a grant are told apart without reading either word.
*
* `wrap` is false in the ledger, where the list scrolls in its own track on a
* narrow screen rather than growing the record to four lines, and true in the
* dialog, where there is room and nothing below to push away.
*/
export function ScopeChips({ scopes, wrap = true }: { scopes: string[]; wrap?: boolean }) {
if (scopes.length === 0) {
// Not an em dash: "unknown" and "this key can call nothing" are
// different facts, and only one of them is true here.
return (
<span className="inline-flex rounded border border-dashed border-border px-1.5 py-0.5 font-mono text-xs text-text-tertiary">
no scopes granted
</span>
);
}
return (
<div className="flex flex-wrap gap-1">
<div className={`flex gap-1.5 ${wrap ? "flex-wrap" : "flex-wrap lg:flex-nowrap lg:overflow-x-auto"}`}>
{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>
<span
key={resource}
className="inline-flex shrink-0 items-stretch overflow-hidden rounded border border-border font-mono text-xs"
>
<span className="px-1.5 py-0.5 text-text-secondary">{resource}</span>
<span
className={`border-l border-border px-1.5 py-0.5 ${
access === "rw" ? "bg-accent/20 text-accent" : "bg-text-secondary/10 text-text-tertiary"
}`}
>
{access}
</span>
</span>
))}
</div>
);