feat: redesign the api key list as a ledger with lifetime bars
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user