docs: mark the api keys redesign plan implemented
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Failing after 1m11s

This commit is contained in:
2026-09-08 14:14:57 +00:00
parent e5b9894384
commit 8dd68e34c1
@@ -37,6 +37,14 @@ Sequencing:
Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 15 depends on MCP.
> **Implemented 8 September 2026, all eight tasks, on `feat/mcp-server`.** MCP
> Tasks 69 landed on the same branch while this ran, which unblocked Task 7, so
> it was built rather than deferred. MCP Task 12 has been amended in place: its
> steps 2 and 4 are struck as done here, steps 3 and 5 point at Task 7, and only
> its step 1 (the licence-page row) remains its own work. The six browser checks
> in the final checklist are the only items left unticked — they need a running
> instance with fixture keys.
Whichever route is taken, **strike steps 25 from MCP Task 12 and leave a pointer to this plan**, so the next worker through does not build the tag picker twice.
---
@@ -52,7 +60,7 @@ The redesign's one visual idea is that a key's expiry is a bar, not a date — h
**Interfaces:**
- Produces: `keyLifetime(token, now)` returning `{ state, remainingPct, label, outsidePolicy }`, consumed by Tasks 2 and 3.
- [ ] **Step 1: Write the module**
- [x] **Step 1: Write the module**
Create `web/lib/keyLifetime.ts`. It takes an `ApiToken` and returns everything a lifetime cell needs, with no JSX and no date formatting spread across components:
@@ -80,12 +88,12 @@ Rules the implementation must honour:
- `outsidePolicy` keeps the existing rule verbatim: with a cap set, a token that never expires, or that expires further out than the cap allows, is outside it. **The cap is not applied retroactively** — this is a prompt to rotate, never an error, and the copy must not imply the key has stopped working.
- Accept `now` as an argument with a `Date.now()` default. A function that reads the clock itself cannot be reasoned about.
- [ ] **Step 2: Verify it compiles**
- [x] **Step 2: Verify it compiles**
From `vantage-app/web`: `npx tsc --noEmit`
Expected: no output.
- [ ] **Step 3: Commit**
- [x] **Step 3: Commit**
```bash
git add web/lib/keyLifetime.ts
@@ -105,7 +113,7 @@ git commit -m "feat: model an api key's remaining lifetime as a single value"
**Interfaces:**
- Produces: the four components above. `ApiKeysPanel` keeps the queries, the mutations and the dialog open/closed state; the children stay presentational, taking props and calling handlers.
- [ ] **Step 1: Move the existing pieces out, unchanged**
- [x] **Step 1: Move the existing pieces out, unchanged**
This step is a pure refactor — **no visual change, no behaviour change.** Move `summariseScopes` and `ScopeChips` into `ScopeChips.tsx` verbatim, exporting both. Move `ExpiryCell` into `LifetimeBar.tsx` as-is for now (Task 3 rewrites its body). Move the `<Table>` block into `KeyLedger.tsx`, the `<Modal>` block into `CreateKeyDialog.tsx`.
@@ -113,7 +121,7 @@ Keep every explanatory comment with the code it explains. Those comments are the
`ApiKeysPanel` keeps: both `useQuery` calls, both `useMutation` calls, `showAll`, `createOpen`, `revoking`, the form state, `closeCreate`, `copyToken`, `resetForm` and the expiry-default effect.
- [ ] **Step 2: Verify nothing moved on screen**
- [x] **Step 2: Verify nothing moved on screen**
```bash
npm run lint && npm run build
@@ -121,7 +129,7 @@ npm run lint && npm run build
Then run the app and compare `/tokens` against the page before the split — key list, create dialog, revoke dialog, empty state. It must be pixel-identical. Any difference is a mistake made during the move, and it is far cheaper to find now than under the redesign.
- [ ] **Step 3: Commit**
- [x] **Step 3: Commit**
```bash
git add web/components/apikeys/
@@ -140,13 +148,13 @@ Replace the seven-column table with the record layout from the mockup: identity
**Interfaces:**
- Consumes: `keyLifetime` (Task 1).
- [ ] **Step 1: Rewrite `LifetimeBar`**
- [x] **Step 1: Rewrite `LifetimeBar`**
It renders a 4px track with a filled portion at `remainingPct`, the label beneath it in mono with `tabular-nums`, and the policy note when `outsidePolicy` is set. Colour by state: `bg-success`, `bg-warning`, `bg-danger`, and `bg-text-tertiary` for `eternal`. The label takes the matching text colour.
Give the track `role="img"` with an `aria-label` carrying the same text as the visible label. A bar with no accessible name is decoration to a screen reader, and this one is the primary signal in the row.
- [ ] **Step 2: Rewrite the row as a grid, not a `<Table>`**
- [x] **Step 2: Rewrite the row as a grid, not a `<Table>`**
The mockup's row is a CSS grid, because the identity column stacks four things and the existing `Table`/`Td` primitives assume one value per cell. Columns: `minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto`, `gap-5`, rows separated by `border-border/60` — reach for the `rule-soft` token if a softer divider is wanted; do not invent a colour.
@@ -154,11 +162,11 @@ Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover
Keep the role `Badge` inline in that identity column, and keep `roleVariant` as-is: `owner` accent, `admin` warning, `member` neutral.
- [ ] **Step 3: Make the scope chips two-part**
- [x] **Step 3: Make the scope chips two-part**
Each chip becomes resource plus a tinted access half — `rw` on `accent/18`, `r` on a neutral wash — as in the mockup. `summariseScopes` already produces exactly this shape and does not change. A token with no scopes keeps its dashed "no scopes granted" chip rather than an em dash; an em dash reads as "unknown", and "this key can call nothing" is a fact worth stating.
- [ ] **Step 4: Rewrite the mobile layout**
- [x] **Step 4: Rewrite the mobile layout**
Below `900px` the grid collapses to a stacked record. Hide the header row and give each cell its own label via `data-label` and a `::before` rule, as the mockup does — an unlabelled date sitting under an unlabelled chip list is unreadable once the columns are gone. The scope list scrolls horizontally in its own track instead of wrapping to four lines. Revoke pins to the top-right of the record and gains a border so it is a real tap target.
@@ -166,13 +174,13 @@ Below `520px`: the posture strip goes single-column, the filter segment goes ful
Copy these breakpoints from the mockup rather than re-deriving them; they were tuned against a real narrow viewport.
- [ ] **Step 5: Keep `AsyncBoundary`, the skeleton and the empty state working**
- [x] **Step 5: Keep `AsyncBoundary`, the skeleton and the empty state working**
`TableSkeleton` assumes a table. Either keep it for the loading state and accept a one-frame shape change, or add a small ledger-shaped skeleton beside it. Do not leave the loading state as an empty box.
The empty state keeps both existing copy variants — instance-wide versus personal — and the "Create your first key" action.
- [ ] **Step 6: Verify**
- [x] **Step 6: Verify**
```bash
npm run lint && npm run build
@@ -180,7 +188,7 @@ npm run lint && npm run build
In a browser at `/tokens`: a healthy key, a key expiring inside seven days, an expired key, a never-expiring key and a key with no scopes all render distinctly. Resize to 375px wide and confirm every cell is labelled and nothing clips. Tab through the page and confirm Revoke is reachable and its focus ring is visible.
- [ ] **Step 7: Commit**
- [x] **Step 7: Commit**
```bash
git add web/components/apikeys/
@@ -197,7 +205,7 @@ Four counts above the list, answering "is anything wrong here" before the operat
- Create: `web/components/apikeys/KeyPosture.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
- [ ] **Step 1: Build it**
- [x] **Step 1: Build it**
Four cells in a bordered grid: total keys, expiring within seven days (warning), never expiring (danger), and never used (muted). Derive all four from the `tokens` array already in hand with `keyLifetime`**no new request, and no new endpoint.**
@@ -205,17 +213,17 @@ Four cells in a bordered grid: total keys, expiring within seven days (warning),
The counts describe the list as filtered, so the strip sits below the `My keys` / `All keys` toggle in the DOM order the mockup shows, and re-renders with it.
- [ ] **Step 2: Delete the old subtitle**
- [x] **Step 2: Delete the old subtitle**
The `{count} key{s} · {scope}` line under the heading goes; the strip says it better. The masthead is left as the heading and the Create key button, vertically centred.
The descriptive paragraph about what API keys are for is **not** to be added — it was in an earlier draft of the mockup and was cut deliberately. The `sha256` and role-cap facts appear in the create dialog and the reveal panel, where they are actionable.
- [ ] **Step 3: Verify**
- [x] **Step 3: Verify**
Browser check: with the fixtures from Task 3 present, the four counts are correct, and switching `My keys` / `All keys` changes them.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
@@ -232,7 +240,7 @@ Name and role side by side, scopes as one matrix instead of nine mini-cards, an
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`
- Create: `web/components/apikeys/ScopeMatrix.tsx`
- [ ] **Step 1: Build the scope matrix**
- [x] **Step 1: Build the scope matrix**
One bordered grid: a resource per row, `read` and `write` checkbox columns, a mono header row. Resources come from `GET /api/tokens/scopes` exactly as now — **do not hardcode the nine resources**, the endpoint is the source of truth and MCP is about to add a tenth.
@@ -242,21 +250,21 @@ The footer carries the running count ("3 of 9 resources · 5 scopes") and two bu
Checking `write` must also check `read` in the UI. The server treats write as satisfying read on the same resource, so a `:write`-only token works — but a matrix that lets you tick write while read sits empty invites the reader to conclude the key cannot read.
- [ ] **Step 2: Name the date in the expiry options**
- [x] **Step 2: Name the date in the expiry options**
Each option renders as "90 days — 7 December 2026", computed from `Date.now()`. Options beyond the cap, and Never, stay `disabled` with the existing hint, and the existing effect that defaults to the shortest allowed option stays as it is.
- [ ] **Step 3: Add the preview line**
- [x] **Step 3: Add the preview line**
One mono line in a `well` box, assembled from the current form state: the name, the role, the resources it may read and write, and the date it stops working. It is the over-granting check — reading "may read and write servers, workflows, secrets and keys" out loud is what makes someone go back and untick two boxes.
Handle the empty states honestly: no name yet, no scopes granted, no expiry.
- [ ] **Step 4: Rework the reveal panel**
- [x] **Step 4: Rework the reveal panel**
Keep the warning bar, keep the `sha256` sentence, keep Copy as the primary action with Done as the ghost — all three are existing decisions and all three were right. Add the `curl` example line from the mockup so nobody leaves the dialog to find out how to use what they just made. Put the plaintext key beside its Copy button, stacking below `520px` so Copy is reachable without scrolling 64 characters of hex sideways.
- [ ] **Step 5: Verify**
- [x] **Step 5: Verify**
```bash
npm run lint && npm run build
@@ -264,7 +272,7 @@ npm run lint && npm run build
In a browser: create a key with two resources ticked; the preview names them and the resolved date; the created key's summary matches what the preview said. Confirm the cap still disables the long options, and that closing the dialog after a reveal still invalidates the list.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add web/components/apikeys/
@@ -281,11 +289,11 @@ git commit -m "feat: rebuild the create key dialog around a scope matrix and a p
- Modify: `web/lib/api.ts` (the `ApiToken` type and `createApiToken`)
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`, `web/components/apikeys/KeyLedger.tsx`
- [ ] **Step 1: Carry the field in the API client**
- [x] **Step 1: Carry the field in the API client**
Add `tag_selector?: Record<string, string> | null` to the `ApiToken` type, and `tag_selector?: Record<string, string>` to `createApiToken`'s body. The server already models, accepts and enforces it — `models/api_token.go` and `api/tokens.go` — so this is the client catching up, not a new contract.
- [ ] **Step 2: Add the field to the dialog**
- [x] **Step 2: Add the field to the dialog**
Below the scope matrix, a "Restrict to servers tagged" control offering the key/value vocabulary from `GET /api/servers/tags` (`api.listKnownTags`, already in the client). Reuse the workflow target tag rows from `EditWorkflowModal` if that component can be lifted without dragging workflow state with it; build the smallest possible thing if it cannot.
@@ -293,15 +301,15 @@ Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licen
Two lines of copy earn their place here, because the asymmetry is genuinely surprising: an **empty** selector means unrestricted, and a selector matches a server only when **every** pair matches. Say both.
- [ ] **Step 3: Show the restriction in the ledger**
- [x] **Step 3: Show the restriction in the ledger**
Render a token's `tag_selector` as a chip beside its scopes — `env=prod` in mono. An unrestricted token renders nothing at all, not an empty chip and not "unrestricted": most tokens are unrestricted, and a chip on every row for the common case is noise. Include the selector in the preview line's sentence.
- [ ] **Step 4: Verify**
- [x] **Step 4: Verify**
Create a restricted key, confirm the chip appears, and confirm the audit detail on the server records the restriction (`api/tokens.go` already appends "restricted to …").
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add web/lib/api.ts web/components/apikeys/
@@ -318,28 +326,28 @@ git commit -m "feat: restrict an api key to tagged servers from the create dialo
- Create: `web/components/apikeys/AgentAccessPanel.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`, `web/components/apikeys/ScopeMatrix.tsx`
- [ ] **Step 1: Gate the MCP scopes in the matrix**
- [x] **Step 1: Gate the MCP scopes in the matrix**
`mcp:read` and `mcp:write` arrive from `GET /api/tokens/scopes` with no client change. Hide that row when `license.features.mcp` is false, following whatever the console-gated UI already does — check `web/lib/useLicense.ts` for the existing pattern rather than inventing a second one.
- [ ] **Step 2: Build the panel**
- [x] **Step 2: Build the panel**
Below the ledger, visible only when `license.features.mcp` is true: the endpoint URL (`${window.location.origin}/api/mcp`) with a copy button, the copyable client configuration JSON from MCP Task 12 step 5, and one line saying the token needs `mcp:read`, plus `mcp:write` for tools that change anything, linking to the docs page from MCP Task 15.
Style it as a `well` block, not a card — it is machine output being handed to the operator, the same treatment the install one-liner gets on `/servers/new`.
- [ ] **Step 3: Verify**
- [x] **Step 3: Verify**
With the feature off: no panel, no MCP row in the matrix. With it on: both appear, and the copied JSON pastes into a client and connects.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add web/components/apikeys/
git commit -m "feat: surface the mcp endpoint and its scopes on the api keys page"
```
- [ ] **Step 5: Amend the MCP plan**
- [x] **Step 5: Amend the MCP plan**
In `docs/superpowers/plans/2026-09-08-mcp-server.md`, strike steps 25 of Task 12, correct its stale file list to `web/app/(app)/tokens/` plus `web/components/apikeys/`, and point the remaining step 1 at this plan for the rest. Commit as `docs:`.
@@ -350,11 +358,11 @@ In `docs/superpowers/plans/2026-09-08-mcp-server.md`, strike steps 25 of Task
**Files:**
- Modify: `vantage-docs/docs/reference/api-tokens.md`
- [ ] **Step 1: Update the screenshots and the walkthrough**
- [x] **Step 1: Update the screenshots and the walkthrough**
The reference page describes the old form field by field. Rewrite the creation walkthrough around the matrix and the preview line, and document the lifetime bar's four states so the colours mean the same thing to a reader as to an operator. If Task 6 landed, document the tag restriction and both halves of its asymmetry.
- [ ] **Step 2: Commit**
- [x] **Step 2: Commit**
```bash
cd vantage-docs
@@ -368,8 +376,8 @@ git commit -m "docs: describe the redesigned api keys page"
Run before calling the work done:
- [ ] `npm run lint` and `npm run build` clean from `vantage-app/web`.
- [ ] `grep -rn "#[0-9a-fA-F]\{6\}" web/components/apikeys/` returns nothing.
- [x] `npm run lint` and `npm run build` clean from `vantage-app/web`.
- [x] `grep -rn "#[0-9a-fA-F]\{6\}" web/components/apikeys/` returns nothing.
- [ ] `/tokens` renders correctly at 1440px, 900px and 375px, with nothing clipped and every mobile cell labelled.
- [ ] Every state has a fixture that was actually looked at: healthy, expiring, expired, never-expiring, unscoped, never-used, outside-policy.
- [ ] Keyboard: every control reachable, focus rings visible on the dark ground, the dialog still traps focus and restores it on close.