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