refactor: split the api keys panel into ledger, chips, lifetime and dialog

This commit is contained in:
2026-09-08 14:06:02 +00:00
parent 5e4c8afdd1
commit aedc388535
5 changed files with 345 additions and 258 deletions
+24 -258
View File
@@ -6,117 +6,28 @@ import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
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";
import { KeyLedger } from "./KeyLedger";
import { CreateKeyDialog, EXPIRY_OPTIONS } from "./CreateKeyDialog";
const ROLES: Role[] = ["owner", "admin", "member"];
const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
{ label: "30 days", days: 30 },
{ label: "60 days", days: 60 },
{ label: "90 days", days: 90 },
{ label: "365 days", days: 365 },
{ label: "Never", days: null },
];
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** The token a pending revoke refers to, carried so the dialog and the
* confirmation message name a token rather than a token_id. */
type PendingRevoke = { id: string; name: string };
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
function rolesAtOrBelow(role: Role): Role[] {
const idx = ROLES.indexOf(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. */
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);
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 expiresAt = new Date(token.expires_at);
const expired = expiresAt.getTime() <= Date.now();
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
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>
);
}
/**
* The whole API Keys page body, header included.
*
@@ -293,52 +204,7 @@ export function ApiKeysPanel() {
/>
}
>
<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>
<KeyLedger tokens={tokens ?? []} showAll={showAll} capDays={capDays} onRevoke={setRevoking} />
</AsyncBoundary>
</Card>
@@ -361,127 +227,27 @@ export function ApiKeysPanel() {
}
/>
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
{result ? (
<div className="space-y-4">
<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="ghost" onClick={closeCreate}>
Done
</Button>
<Button type="button" variant="primary" onClick={copyToken}>
{copied ? "Copied" : "Copy key"}
</Button>
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
createToken();
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
const writeScope = `${r}:write`;
return (
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
<span className="text-sm capitalize text-text-primary">{r}</span>
<div className="flex gap-3">
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(readScope)}
onChange={() => toggleScope(readScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(writeScope)}
onChange={() => toggleScope(writeScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
write
</label>
</div>
</div>
);
})}
</div>
</Field>
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
className={inputClass}
>
{EXPIRY_OPTIONS.map((o) => {
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
return (
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
{o.label}
</option>
);
})}
</select>
</Field>
{createError && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeCreate}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create key
</Button>
</div>
</form>
)}
</Modal>
<CreateKeyDialog
open={createOpen}
onClose={closeCreate}
result={result}
copied={copied}
onCopy={copyToken}
name={name}
setName={setName}
role={role}
setRole={setRole}
assignableRoles={assignableRoles}
resources={resources}
scopes={scopes}
toggleScope={toggleScope}
expiryDays={expiryDays}
setExpiryDays={setExpiryDays}
capDays={capDays}
creating={creating}
createError={createError}
onSubmit={createToken}
/>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import type { ApiToken, Role } from "@/lib/api";
import { Button, Modal, friendlyMessage } from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
import { ScopeChips } from "./ScopeChips";
export const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [
{ label: "30 days", days: 30 },
{ label: "60 days", days: 60 },
{ label: "90 days", days: 90 },
{ label: "365 days", days: 365 },
{ label: "Never", days: null },
];
export function CreateKeyDialog({
open,
onClose,
result,
copied,
onCopy,
name,
setName,
role,
setRole,
assignableRoles,
resources,
scopes,
toggleScope,
expiryDays,
setExpiryDays,
capDays,
creating,
createError,
onSubmit,
}: {
open: boolean;
onClose: () => void;
result: { token: string; record: ApiToken } | null;
copied: boolean;
onCopy: () => void;
name: string;
setName: (v: string) => void;
role: Role;
setRole: (v: Role) => void;
assignableRoles: Role[];
resources: string[];
scopes: string[];
toggleScope: (s: string) => void;
expiryDays: number | null;
setExpiryDays: (v: number | null) => void;
capDays: number;
creating: boolean;
createError: unknown;
onSubmit: () => void;
}) {
return (
<Modal open={open} title={result ? "Key created" : "New API key"} onClose={onClose}>
{result ? (
<div className="space-y-4">
<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="ghost" onClick={onClose}>
Done
</Button>
<Button type="button" variant="primary" onClick={onCopy}>
{copied ? "Copied" : "Copy key"}
</Button>
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
const writeScope = `${r}:write`;
return (
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
<span className="text-sm capitalize text-text-primary">{r}</span>
<div className="flex gap-3">
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(readScope)}
onChange={() => toggleScope(readScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(writeScope)}
onChange={() => toggleScope(writeScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
write
</label>
</div>
</div>
);
})}
</div>
</Field>
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
onChange={(e) => setExpiryDays(e.target.value === "never" ? null : Number(e.target.value))}
className={inputClass}
>
{EXPIRY_OPTIONS.map((o) => {
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
return (
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
{o.label}
</option>
);
})}
</select>
</Field>
{createError ? (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>
) : null}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create key
</Button>
</div>
</form>
)}
</Modal>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { ApiToken, Role } from "@/lib/api";
import { Badge, Button, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
import { ScopeChips } from "./ScopeChips";
import { ExpiryCell } from "./LifetimeBar";
export function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
return "neutral" as const;
}
export function KeyLedger({
tokens,
showAll,
capDays,
onRevoke,
}: {
tokens: ApiToken[];
showAll: boolean;
capDays: number;
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">
<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>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ApiToken } from "@/lib/api";
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** 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);
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 expiresAt = new Date(token.expires_at);
const expired = expiresAt.getTime() <= Date.now();
const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS;
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>
);
}
+38
View File
@@ -0,0 +1,38 @@
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
* 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.
*/
export 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" : "",
}));
}
export 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>
);
}