From aedc388535600d2e195ab24ce35aa3a38b246306 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 14:06:02 +0000 Subject: [PATCH] refactor: split the api keys panel into ledger, chips, lifetime and dialog --- web/components/apikeys/ApiKeysPanel.tsx | 282 ++------------------- web/components/apikeys/CreateKeyDialog.tsx | 180 +++++++++++++ web/components/apikeys/KeyLedger.tsx | 71 ++++++ web/components/apikeys/LifetimeBar.tsx | 32 +++ web/components/apikeys/ScopeChips.tsx | 38 +++ 5 files changed, 345 insertions(+), 258 deletions(-) create mode 100644 web/components/apikeys/CreateKeyDialog.tsx create mode 100644 web/components/apikeys/KeyLedger.tsx create mode 100644 web/components/apikeys/LifetimeBar.tsx create mode 100644 web/components/apikeys/ScopeChips.tsx diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx index 393acc3..5bc2874 100644 --- a/web/components/apikeys/ApiKeysPanel.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -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(); - 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 ; - return ( -
- {summariseScopes(scopes).map(({ resource, access }) => ( - - {resource} - {access} - - ))} -
- ); -} - -/** 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 ( -
- — never - {outsidePolicy &&

outside the current policy — rotate when convenient

} -
- ); - } - - const expiresAt = new Date(token.expires_at); - const expired = expiresAt.getTime() <= Date.now(); - const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS; - - return ( -
- - {expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()} - - {outsidePolicy &&

outside the current policy — rotate when convenient

} -
- ); -} - /** * The whole API Keys page body, header included. * @@ -293,52 +204,7 @@ export function ApiKeysPanel() { /> } > - - - - - {showAll && } - - - - - - - - - {tokens?.map((t) => ( - - - {showAll && } - - - - - - - ))} - -
NameOwnerRoleScopesLast usedExpiresActions
- {t.name} -
{t.hint}…
-
{t.user_email ?? t.user_id} - {t.role} - - - - {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : Never used} - - - - -
+ @@ -361,127 +227,27 @@ export function ApiKeysPanel() { } /> - - {result ? ( -
-
- This is the only time {result.record.name} is shown. Copy it now — Vantage stores only a - hash and cannot show it again. -
- {result.token} -
-
Role
-
{result.record.role}
-
Scopes
-
- -
-
Expires
-
- {result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"} -
-
- {/* 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. */} -
- - -
-
- ) : ( -
{ - e.preventDefault(); - createToken(); - }} - className="space-y-4" - > - - setName(e.target.value)} className={inputClass} /> - - - - - - - -
- {resources.map((r) => { - const readScope = `${r}:read`; - const writeScope = `${r}:write`; - return ( -
- {r} -
- - -
-
- ); - })} -
-
- - 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."} - > - - - - {createError &&
{friendlyMessage(createError)}
} - -
- - -
-
- )} -
+ ); } diff --git a/web/components/apikeys/CreateKeyDialog.tsx b/web/components/apikeys/CreateKeyDialog.tsx new file mode 100644 index 0000000..6edb7a8 --- /dev/null +++ b/web/components/apikeys/CreateKeyDialog.tsx @@ -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 ( + + {result ? ( +
+
+ This is the only time {result.record.name} is shown. Copy it now — Vantage stores only a + hash and cannot show it again. +
+ {result.token} +
+
Role
+
{result.record.role}
+
Scopes
+
+ +
+
Expires
+
+ {result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"} +
+
+ {/* 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. */} +
+ + +
+
+ ) : ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-4" + > + + setName(e.target.value)} className={inputClass} /> + + + + + + + +
+ {resources.map((r) => { + const readScope = `${r}:read`; + const writeScope = `${r}:write`; + return ( +
+ {r} +
+ + +
+
+ ); + })} +
+
+ + 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."} + > + + + + {createError ? ( +
{friendlyMessage(createError)}
+ ) : null} + +
+ + +
+
+ )} +
+ ); +} diff --git a/web/components/apikeys/KeyLedger.tsx b/web/components/apikeys/KeyLedger.tsx new file mode 100644 index 0000000..898c969 --- /dev/null +++ b/web/components/apikeys/KeyLedger.tsx @@ -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 ( + + + + + {showAll && } + + + + + + + + + {tokens.map((t) => ( + + + {showAll && } + + + + + + + ))} + +
NameOwnerRoleScopesLast usedExpiresActions
+ {t.name} +
{t.hint}…
+
{t.user_email ?? t.user_id} + {t.role} + + + + {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : Never used} + + + + +
+ ); +} diff --git a/web/components/apikeys/LifetimeBar.tsx b/web/components/apikeys/LifetimeBar.tsx new file mode 100644 index 0000000..77d2d6d --- /dev/null +++ b/web/components/apikeys/LifetimeBar.tsx @@ -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 ( +
+ — never + {outsidePolicy &&

outside the current policy — rotate when convenient

} +
+ ); + } + + const expiresAt = new Date(token.expires_at); + const expired = expiresAt.getTime() <= Date.now(); + const soon = !expired && expiresAt.getTime() - Date.now() <= SEVEN_DAYS_MS; + + return ( +
+ + {expired ? `Expired ${expiresAt.toLocaleDateString()}` : expiresAt.toLocaleDateString()} + + {outsidePolicy &&

outside the current policy — rotate when convenient

} +
+ ); +} diff --git a/web/components/apikeys/ScopeChips.tsx b/web/components/apikeys/ScopeChips.tsx new file mode 100644 index 0000000..d63f7d0 --- /dev/null +++ b/web/components/apikeys/ScopeChips.tsx @@ -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(); + 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 ; + return ( +
+ {summariseScopes(scopes).map(({ resource, access }) => ( + + {resource} + {access} + + ))} +
+ ); +}