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