feat: summarise key posture above the ledger

This commit is contained in:
2026-09-08 14:09:00 +00:00
parent 67ac029354
commit a0641e8ecb
2 changed files with 39 additions and 6 deletions
+4 -6
View File
@@ -14,6 +14,7 @@ import {
useToast,
} from "@/components/ui";
import { KeyLedger, LedgerSkeleton } from "./KeyLedger";
import { KeyPosture } from "./KeyPosture";
import { CreateKeyDialog, EXPIRY_OPTIONS } from "./CreateKeyDialog";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -137,12 +138,7 @@ export function ApiKeysPanel() {
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">
{count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}
</p>
</div>
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
<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" />
@@ -176,6 +172,8 @@ export function ApiKeysPanel() {
</div>
)}
{count > 0 && <KeyPosture tokens={tokens ?? []} capDays={capDays} />}
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
+35
View File
@@ -0,0 +1,35 @@
import type { ApiToken } from "@/lib/api";
import { keyLifetime } from "@/lib/keyLifetime";
/*
* Four counts above the list, answering "is anything wrong here" before a
* single row is read. All four are derived from the tokens already in hand —
* no second request, and no endpoint that could disagree with the list.
*
* The counts describe the list as filtered, so this sits below the My keys /
* All keys toggle and moves with it.
*/
export function KeyPosture({ tokens, capDays }: { tokens: ApiToken[]; capDays: number }) {
const now = Date.now();
const lifetimes = tokens.map((t) => keyLifetime(t, capDays, now));
const cells: { n: number; label: string; tone: string }[] = [
{ n: tokens.length, label: `key${tokens.length === 1 ? "" : "s"} listed`, tone: "text-text-primary" },
{ n: lifetimes.filter((l) => l.state === "soon").length, label: "expire within 7 days", tone: "text-warning" },
{ n: lifetimes.filter((l) => l.state === "eternal").length, label: "never expire", tone: "text-danger" },
// Muted, not coloured: an unused key is a cleanup candidate, not an
// incident.
{ n: tokens.filter((t) => !t.last_used_at).length, label: "unused since issue", tone: "text-text-secondary" },
];
return (
<div className="mb-6 grid gap-px overflow-hidden rounded-lg border border-border bg-border-soft sm:grid-cols-2 lg:grid-cols-4">
{cells.map((c) => (
<div key={c.label} className="flex items-baseline gap-2.5 bg-surface px-3.5 py-3 lg:flex-col lg:gap-0.5">
<span className={`font-mono text-xl font-semibold tabular-nums tracking-tight ${c.tone}`}>{c.n}</span>
<span className="text-xs text-text-tertiary">{c.label}</span>
</div>
))}
</div>
);
}