feat: rebuild the create key dialog around a scope matrix and a preview

This commit is contained in:
2026-09-08 14:10:37 +00:00
parent a0641e8ecb
commit ac9cc57e7e
3 changed files with 217 additions and 55 deletions
+1
View File
@@ -238,6 +238,7 @@ export function ApiKeysPanel() {
resources={resources}
scopes={scopes}
toggleScope={toggleScope}
setScopes={setScopes}
expiryDays={expiryDays}
setExpiryDays={setExpiryDays}
capDays={capDays}
+104 -55
View File
@@ -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 (
<p className="rounded border border-border-soft bg-well px-3 py-2.5 font-mono text-xs leading-relaxed text-text-secondary">
<span className="text-text-primary">{name.trim() || "This key"}</span> acts as{" "}
<span className="text-text-primary">{role}</span>,{" "}
{grants.length ? (
<>
may <span className="text-text-primary">{grants.join(", and ")}</span>
</>
) : (
<span className="text-warning">can call nothing until a scope is granted</span>
)}
, and{" "}
{expiryDays === null ? (
<span className="text-warning">never expires</span>
) : (
<>
stops working on <span className="text-warning">{expiryDate(expiryDays)}</span>
</>
)}
.
</p>
);
}
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 (
<Modal open={open} title={result ? "Key created" : "New API key"} onClose={onClose}>
<Modal open={open} title={result ? `${result.record.name} is ready` : "Create key"} onClose={onClose}>
{result ? (
<div className="space-y-4">
<div className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
This is the only time <span className="font-semibold">{result.record.name}</span> 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.
</div>
<code className="block overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{/* Below sm the button drops beneath the value: Copy has to
be reachable without scrolling 64 characters of hex. */}
<div className="flex flex-col overflow-hidden rounded border border-border bg-well sm:flex-row">
<code className="flex-1 overflow-x-auto whitespace-nowrap p-3 font-mono text-sm text-text-primary">{result.token}</code>
<button
type="button"
onClick={onCopy}
className="border-t border-border bg-surface-2 px-4 py-2.5 text-sm text-text-primary hover:bg-border sm:border-l sm:border-t-0"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
<dl className="grid grid-cols-[88px_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
<dt className="text-text-secondary">Role</dt>
<dd className="text-text-primary">{result.record.role}</dd>
<dt className="text-text-secondary">Scopes</dt>
@@ -72,11 +133,21 @@ export function CreateKeyDialog({
<dd className="text-text-primary">
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
</dd>
{/* So nobody leaves the dialog to find out how to use
what they just made, while the value is on screen. */}
<dt className="text-text-secondary">Use it</dt>
<dd className="overflow-x-auto">
<code className="whitespace-nowrap font-mono text-xs text-text-secondary">
curl -H &quot;Authorization: Bearer {result.record.hint}&quot; {typeof window !== "undefined" ? window.location.origin : ""}
/api/servers
</code>
</dd>
</dl>
{/* 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. */}
<div className="flex justify-end gap-2">
<div className="flex flex-col-reverse justify-end gap-2 sm:flex-row">
<Button type="button" variant="ghost" onClick={onClose}>
Done
</Button>
@@ -91,59 +162,35 @@ export function CreateKeyDialog({
e.preventDefault();
onSubmit();
}}
className="space-y-4"
className="space-y-5"
>
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Name" hint="What will use this key — the CI pipeline, the script, the cluster.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
<Field label="Role" hint="A key never outranks the person who made it.">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{assignableRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
</div>
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
const writeScope = `${r}:write`;
return (
<div key={r} className="flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3 py-2">
<span className="text-sm capitalize text-text-primary">{r}</span>
<div className="flex gap-3">
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(readScope)}
onChange={() => toggleScope(readScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
<input
type="checkbox"
checked={scopes.includes(writeScope)}
onChange={() => toggleScope(writeScope)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
write
</label>
</div>
</div>
);
})}
</div>
<Field label="Scopes" hint="Grant only what the caller actually needs. Write already covers read.">
<ScopeMatrix resources={resources} scopes={scopes} onToggle={toggleScope} onSet={setScopes} />
</Field>
<Field
label="Expires"
hint={capDays > 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."
}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
@@ -154,18 +201,20 @@ export function CreateKeyDialog({
const disabled = capDays > 0 && (o.days === null || o.days > capDays);
return (
<option key={o.label} value={o.days === null ? "never" : String(o.days)} disabled={disabled}>
{o.label}
{o.days === null ? o.label : `${o.label}${expiryDate(o.days)}`}
</option>
);
})}
</select>
</Field>
<PreviewLine name={name} role={role} scopes={scopes} expiryDays={expiryDays} />
{createError ? (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{friendlyMessage(createError)}</div>
) : null}
<div className="flex justify-end gap-2">
<div className="flex flex-col-reverse justify-end gap-2 sm:flex-row">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
+112
View File
@@ -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<string, string> = {
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 (
<div className="overflow-hidden rounded border border-border">
<div className="grid grid-cols-[1fr_56px_56px] items-center border-b border-border bg-surface-2 px-3 py-2 font-mono text-[0.65rem] uppercase tracking-[0.08em] text-text-tertiary">
<span>Resource</span>
<span className="text-center">Read</span>
<span className="text-center">Write</span>
</div>
{resources.map((r) => {
const read = `${r}:read`;
const write = `${r}:write`;
return (
<div
key={r}
className="grid grid-cols-[1fr_56px_56px] items-center border-b border-border-soft px-3 py-2 last:border-b-0"
>
<span className="text-sm text-text-primary">
{r}
{DESCRIPTIONS[r] && <span className="block text-xs text-text-tertiary">{DESCRIPTIONS[r]}</span>}
</span>
<label className="flex justify-center">
<span className="sr-only">read {r}</span>
<input
type="checkbox"
checked={granted.has(read)}
onChange={() => onToggle(read)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
</label>
<label className="flex justify-center">
<span className="sr-only">write {r}</span>
<input
type="checkbox"
checked={granted.has(write)}
onChange={() => toggleWrite(r)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
</label>
</div>
);
})}
<div className="flex flex-col gap-1.5 border-t border-border bg-surface-2 px-3 py-2 text-xs text-text-tertiary sm:flex-row sm:items-center sm:justify-between">
<span>
{resourceCount} of {resources.length} resources · {scopes.length} scope{scopes.length === 1 ? "" : "s"}
</span>
<span className="flex gap-3">
<button type="button" className="text-accent hover:underline" onClick={() => onSet(resources.map((r) => `${r}:read`))}>
Read-only everywhere
</button>
<button type="button" className="text-accent hover:underline" onClick={() => onSet([])}>
Clear all
</button>
</span>
</div>
</div>
);
}