From ac9cc57e7e318560de59edc39a84418ec393b171 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 14:10:37 +0000 Subject: [PATCH] feat: rebuild the create key dialog around a scope matrix and a preview --- web/components/apikeys/ApiKeysPanel.tsx | 1 + web/components/apikeys/CreateKeyDialog.tsx | 159 ++++++++++++++------- web/components/apikeys/ScopeMatrix.tsx | 112 +++++++++++++++ 3 files changed, 217 insertions(+), 55 deletions(-) create mode 100644 web/components/apikeys/ScopeMatrix.tsx diff --git a/web/components/apikeys/ApiKeysPanel.tsx b/web/components/apikeys/ApiKeysPanel.tsx index d8ee83f..bfab782 100644 --- a/web/components/apikeys/ApiKeysPanel.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -238,6 +238,7 @@ export function ApiKeysPanel() { resources={resources} scopes={scopes} toggleScope={toggleScope} + setScopes={setScopes} expiryDays={expiryDays} setExpiryDays={setExpiryDays} capDays={capDays} diff --git a/web/components/apikeys/CreateKeyDialog.tsx b/web/components/apikeys/CreateKeyDialog.tsx index 6edb7a8..ce706aa 100644 --- a/web/components/apikeys/CreateKeyDialog.tsx +++ b/web/components/apikeys/CreateKeyDialog.tsx @@ -1,7 +1,8 @@ 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"; +import { ScopeChips, summariseScopes } from "./ScopeChips"; +import { ScopeMatrix } from "./ScopeMatrix"; export const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [ { label: "30 days", days: 30 }, @@ -11,6 +12,52 @@ export const EXPIRY_OPTIONS: { label: string; days: number | null }[] = [ { label: "Never", days: null }, ]; +function expiryDate(days: number, now = Date.now()) { + return new Date(now + days * 24 * 60 * 60 * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" }); +} + +/** + * The key read back as a sentence before it exists. + * + * Ticking eleven boxes and reading eleven boxes back are the same act, so the + * form cannot catch an over-grant on its own. A sentence can: reading "may read + * and write servers, workflows, secrets and keys" out loud is what sends + * somebody back to untick two of them. + */ +function PreviewLine({ name, role, scopes, expiryDays }: { name: string; role: Role; scopes: string[]; expiryDays: number | null }) { + const summary = summariseScopes(scopes); + const rw = summary.filter((s) => s.access === "rw").map((s) => s.resource); + const ro = summary.filter((s) => s.access === "r").map((s) => s.resource); + const list = (xs: string[]) => (xs.length > 1 ? `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}` : xs[0]); + + const grants: string[] = []; + if (rw.length) grants.push(`read and write ${list(rw)}`); + if (ro.length) grants.push(`read ${list(ro)}`); + + return ( +

+ {name.trim() || "This key"} acts as{" "} + {role},{" "} + {grants.length ? ( + <> + may {grants.join(", and ")} + + ) : ( + can call nothing until a scope is granted + )} + , and{" "} + {expiryDays === null ? ( + never expires + ) : ( + <> + stops working on {expiryDate(expiryDays)} + + )} + . +

+ ); +} + export function CreateKeyDialog({ open, onClose, @@ -25,6 +72,7 @@ export function CreateKeyDialog({ resources, scopes, toggleScope, + setScopes, expiryDays, setExpiryDays, capDays, @@ -45,6 +93,7 @@ export function CreateKeyDialog({ resources: string[]; scopes: string[]; toggleScope: (s: string) => void; + setScopes: (s: string[]) => void; expiryDays: number | null; setExpiryDays: (v: number | null) => void; capDays: number; @@ -53,15 +102,27 @@ export function CreateKeyDialog({ 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. + This is the only time the key is shown. Copy it now — Vantage stores only a hash and cannot show it again.
- {result.token} -
+ + {/* Below sm the button drops beneath the value: Copy has to + be reachable without scrolling 64 characters of hex. */} +
+ {result.token} + +
+ +
Role
{result.record.role}
Scopes
@@ -72,11 +133,21 @@ export function CreateKeyDialog({
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
+ {/* So nobody leaves the dialog to find out how to use + what they just made, while the value is on screen. */} +
Use it
+
+ + curl -H "Authorization: Bearer {result.record.hint}…" {typeof window !== "undefined" ? window.location.origin : ""} + /api/servers + +
+ {/* 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. */} -
+
@@ -91,59 +162,35 @@ export function CreateKeyDialog({ e.preventDefault(); onSubmit(); }} - className="space-y-4" + className="space-y-5" > - - setName(e.target.value)} className={inputClass} /> - +
+ + 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."} + hint={ + capDays > 0 + ? `This instance caps new keys at ${capDays} days. Longer options, and Never, are disabled.` + : "Never means the key has no expiry." + } > + + {createError ? (
{friendlyMessage(createError)}
) : null} -
+
diff --git a/web/components/apikeys/ScopeMatrix.tsx b/web/components/apikeys/ScopeMatrix.tsx new file mode 100644 index 0000000..3d9f5bc --- /dev/null +++ b/web/components/apikeys/ScopeMatrix.tsx @@ -0,0 +1,112 @@ +/* + * One grid: a resource per row, read and write per column. + * + * Nine bordered cards each holding two checkboxes made the grant look like nine + * decisions. It is one decision with a shape, and a matrix is the shape. + * + * Resources come from GET /api/tokens/scopes and are never hardcoded here — + * the endpoint is the source of truth and the vocabulary grows. + */ + +/** UI copy with no server counterpart: what a resource covers, in the words a + * person granting it would use. An unknown resource simply gets no line. */ +const DESCRIPTIONS: Record = { + servers: "fleet list, inventory, agent updates", + keys: "SSH keys and their assignments", + secrets: "vault groups and values", + workflows: "steps, runs and logs", + monitors: "checks, incidents, uptime", + vulns: "findings, rescans, acceptances", + workloads: "containers and services", + status: "status pages and incidents", + settings: "instance settings and API keys", + mcp: "agent access over MCP", +}; + +export function ScopeMatrix({ + resources, + scopes, + onToggle, + onSet, +}: { + resources: string[]; + scopes: string[]; + /** Toggles one scope string, e.g. "servers:write". */ + onToggle: (scope: string) => void; + /** Replaces the whole selection, for the bulk actions. */ + onSet: (scopes: string[]) => void; +}) { + const granted = new Set(scopes); + const resourceCount = resources.filter((r) => granted.has(`${r}:read`) || granted.has(`${r}:write`)).length; + + function toggleWrite(resource: string) { + const read = `${resource}:read`; + const write = `${resource}:write`; + if (granted.has(write)) { + onToggle(write); + return; + } + // Write satisfies read on the server, so a :write-only token works. A + // matrix that let write sit ticked above an empty read box would still + // read as "this key cannot read", which is the wrong conclusion. + onSet(Array.from(new Set([...scopes, write, read]))); + } + + return ( +
+
+ Resource + Read + Write +
+ + {resources.map((r) => { + const read = `${r}:read`; + const write = `${r}:write`; + return ( +
+ + {r} + {DESCRIPTIONS[r] && {DESCRIPTIONS[r]}} + + + +
+ ); + })} + +
+ + {resourceCount} of {resources.length} resources · {scopes.length} scope{scopes.length === 1 ? "" : "s"} + + + + + +
+
+ ); +}