diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx index 5bc2874..7602fce 100644 --- a/web/components/apikeys/ApiKeysPanel.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -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() { } + skeleton={} isEmpty={count === 0} empty={ : 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 ( - - - - - {showAll && } - - - - - - - - - {tokens.map((t) => ( - - - {showAll && } - - - - - - - ))} - -
NameOwnerRoleScopesLast usedExpiresActions
- {t.name} -
{t.hint}…
-
{t.user_email ?? t.user_id} +
+
+ Key {showAll && "/ holder"} + Scopes + Lifetime + Last call + Actions +
+ + {tokens.map((t) => ( +
+
+ {t.name} + {t.hint}… + {/* 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. */} + + {showAll && {t.user_email ?? t.user_id}} {t.role} -
- - - {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : Never used} - - - - -
+ + + +
+ +
+ +
+ +
+ +
+ + {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never used"} + +
+ +
+ +
+ + ))} + + ); +} + +/** 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 ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+ ))} +
); } diff --git a/web/components/apikeys/LifetimeBar.tsx b/web/components/apikeys/LifetimeBar.tsx index 77d2d6d..3f12214 100644 --- a/web/components/apikeys/LifetimeBar.tsx +++ b/web/components/apikeys/LifetimeBar.tsx @@ -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 = { + healthy: "bg-success", + soon: "bg-warning", + expired: "bg-danger", + eternal: "bg-text-tertiary", +}; - if (!token.expires_at) { - return ( -
- — never - {outsidePolicy &&

outside the current policy — rotate when convenient

} -
- ); - } +const TEXT: Record = { + 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 ( -
- - {expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()} - - {outsidePolicy &&

outside the current policy — rotate when convenient

} +
+ {/* The bar is the primary signal in the row, so it carries the same + text as the label rather than reading as decoration. */} +
+
+
+ {label} + {outsidePolicy &&

Outside the current policy — rotate when convenient.

}
); } diff --git a/web/components/apikeys/ScopeChips.tsx b/web/components/apikeys/ScopeChips.tsx index d63f7d0..54db747 100644 --- a/web/components/apikeys/ScopeChips.tsx +++ b/web/components/apikeys/ScopeChips.tsx @@ -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 ; +/** + * 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 ( + + no scopes granted + + ); + } + return ( -
+
{summariseScopes(scopes).map(({ resource, access }) => ( - - {resource} - {access} - + + {resource} + + {access} + + ))}
);