feat: rebuild the create key dialog around a scope matrix and a preview
This commit is contained in:
@@ -238,6 +238,7 @@ export function ApiKeysPanel() {
|
||||
resources={resources}
|
||||
scopes={scopes}
|
||||
toggleScope={toggleScope}
|
||||
setScopes={setScopes}
|
||||
expiryDays={expiryDays}
|
||||
setExpiryDays={setExpiryDays}
|
||||
capDays={capDays}
|
||||
|
||||
@@ -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 "Authorization: Bearer {result.record.hint}…" {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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user