From 18495dba6884f5c3a444d8935a6e29e657cb143d Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 13 Aug 2026 08:59:06 +0000 Subject: [PATCH] feat: Restyle the API keys page onto the shared list patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page was hand-rolling its loading spinner, error line and empty paragraph while the rest of the console routes these through AsyncBoundary with a TableSkeleton and an EmptyState — the same drift Async.tsx was written to end. It also passed className="p-0" where Card takes padding={false}. The admin-only scope switch becomes the vulnerabilities page's pill filter rather than a loose checkbox: seeing everyone's keys is a filter over the list, not a preference, and someone who has learned one control has now learned both. Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen badges made the row taller than everything around it and still had to be read one at a time. In the create dialog Copy is now the primary action and Done the quiet one, because the value is unrecoverable once the dialog closes, and the result panel repeats the role, scopes and expiry that were just granted. --- web/components/apikeys/ApiKeysPanel.tsx | 272 ++++++++++++++++-------- 1 file changed, 184 insertions(+), 88 deletions(-) diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx index 6e35cfc..393acc3 100644 --- a/web/components/apikeys/ApiKeysPanel.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -4,7 +4,24 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type ApiToken, type Role } from "@/lib/api"; import { useAuth } from "@/components/AuthProvider"; -import { Badge, Button, Card, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui"; +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"; const ROLES: Role[] = ["owner", "admin", "member"]; @@ -34,6 +51,43 @@ function rolesAtOrBelow(role: Role): 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. */ @@ -168,98 +222,124 @@ export function ApiKeysPanel() { const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES; + const count = tokens?.length ?? 0; + return (

API Keys

- Scoped, personal keys for scripts and CI to call the REST API without a browser session. A key never exceeds your own role. + {count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}

-
- {isAdmin && ( - - )} - -
+
- - {isLoading ? ( -
-
+ {/* Owner and admin can see everyone's keys, so the scope of the list + is a filter rather than a preference — the same pill treatment + the vulnerabilities page uses for its state filter, so a person + who has learned one has learned both. */} + {isAdmin && ( +
+ {[ + { label: "My keys", all: false }, + { label: "All keys", all: true }, + ].map((option) => ( + + ))}
- ) : error ? ( -

{friendlyMessage(error)}

- ) : !tokens || tokens.length === 0 ? ( -

- No API keys yet. Create one to call the REST API from a script or a CI job. -

- ) : ( - - - - - {showAll && } - - - - - - - - - {tokens.map((t) => ( - - - {showAll && } - - - - - - - ))} - -
NameOwnerRoleScopesLast usedExpiresActions
- {t.name} -
{t.hint}…
-
{t.user_email ?? t.user_id} - {t.role} - -
- {t.scopes.map((s) => ( - - {s} - - ))} -
-
- {t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"} - - - - -
)} + + + } + isEmpty={count === 0} + empty={ + {result ? (
-

This is the only time this key will be shown. Store it now.

-
- {result.token} +
+ 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. */}
- - +
) : (