feat: model an api key's remaining lifetime as a single value

This commit is contained in:
2026-09-08 14:03:49 +00:00
parent 0166b17299
commit 5e4c8afdd1
3 changed files with 889 additions and 0 deletions
@@ -0,0 +1,439 @@
<title>Vantage Key Ledger</title>
<style>
:root{
color-scheme: dark;
--ground:#071628;
--panel:#0d2138;
--panel-2:#102842;
--well:#04101f;
--ink:#e4ecf6;
--ink-2:#9fb3ca;
--ink-3:#71879f;
--rule:#1e3855;
--rule-soft:#172c44;
--accent:#5b9be8;
--accent-hover:#7fb2f0;
--accent-ink:#04101f;
--up:#4fb484;
--pend:#d6a63f;
--down:#e2705a;
--sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--mono:ui-monospace,"Cascadia Mono","SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--r:4px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--ground);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;font-size:14px;line-height:1.5}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:2px}
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 96px}
/* ---------- masthead ---------- */
.masthead{display:flex;flex-wrap:wrap;gap:16px;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid var(--rule)}
h1,h2,h3{font-weight:800;letter-spacing:-.03em;text-wrap:balance}
h1{font-size:26px}
.btn{font:inherit;font-weight:500;border-radius:var(--r);border:1px solid transparent;padding:9px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:8px}
.btn-primary{background:var(--accent);color:var(--accent-ink);font-weight:600}
.btn-primary:hover{background:var(--accent-hover)}
.btn-ghost{background:transparent;color:var(--ink-2);border-color:var(--rule)}
.btn-ghost:hover{color:var(--ink);border-color:var(--ink-3)}
.btn-danger{background:transparent;color:var(--down);border-color:transparent;padding:5px 8px;font-size:13px}
.btn-danger:hover{background:rgba(226,112,90,.12)}
/* ---------- posture strip: the summary before the detail ---------- */
.posture{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--rule-soft);border:1px solid var(--rule);border-radius:var(--r);margin:20px 0 24px;overflow:hidden}
.posture div{background:var(--panel);padding:12px 14px;display:flex;flex-direction:column;gap:2px}
.posture .n{font-family:var(--mono);font-size:20px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
.posture .l{font-size:12px;color:var(--ink-3)}
.n.warn{color:var(--pend)} .n.bad{color:var(--down)} .n.dim{color:var(--ink-2)}
/* ---------- filter ---------- */
.filters{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.seg{display:inline-flex;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.seg button{font:inherit;font-size:13px;background:transparent;color:var(--ink-2);border:0;padding:6px 12px;cursor:pointer;white-space:nowrap}
.seg button+button{border-left:1px solid var(--rule)}
.seg button[aria-pressed="true"]{background:var(--panel-2);color:var(--ink);box-shadow:inset 0 -2px 0 var(--accent)}
.filters .spacer{flex:1}
.hint{font-size:12px;color:var(--ink-3)}
/* ---------- ledger ---------- */
.ledger{border:1px solid var(--rule);border-radius:var(--r);background:var(--panel);overflow:hidden}
.lrow{display:grid;grid-template-columns:minmax(220px,1.5fr) minmax(180px,1.3fr) minmax(150px,1fr) 150px auto;gap:20px;align-items:center;padding:14px 18px}
.lhead{padding:9px 18px;background:var(--panel-2);border-bottom:1px solid var(--rule);font-size:11px;color:var(--ink-3);letter-spacing:.06em;font-family:var(--mono)}
.lrow+.lrow{border-top:1px solid var(--rule-soft)}
.lrow:hover{background:var(--panel-2)}
.name{display:flex;flex-direction:column;gap:3px;min-width:0}
.name b{font-weight:600}
.fingerprint{font-family:var(--mono);font-size:12px;color:var(--ink-3)}
.who{font-size:12px;color:var(--ink-3)}
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-family:var(--mono);border:1px solid var(--rule);border-radius:999px;padding:1px 8px;color:var(--ink-2)}
.pill.owner{border-color:rgba(91,155,232,.5);color:var(--accent)}
.pill.admin{border-color:rgba(214,166,63,.5);color:var(--pend)}
.dot{width:5px;height:5px;border-radius:50%;background:currentColor}
/* scope matrix: resource + r/w encoded as filled halves */
.scopes{display:flex;flex-wrap:wrap;gap:5px}
.scope{display:inline-flex;align-items:stretch;border:1px solid var(--rule);border-radius:3px;overflow:hidden;font-family:var(--mono);font-size:11px}
.scope span{padding:1px 6px;color:var(--ink-2)}
.scope i{font-style:normal;padding:1px 5px;border-left:1px solid var(--rule);color:var(--ink-3)}
.scope.rw i{background:rgba(91,155,232,.18);color:var(--accent)}
.scope.r i{background:rgba(159,179,202,.1)}
.scope.none{color:var(--ink-3);border-style:dashed}
/* lifetime bar: the redesign's one visual idea */
.life{display:flex;flex-direction:column;gap:5px}
.track{height:4px;border-radius:2px;background:var(--rule-soft);overflow:hidden}
.track b{display:block;height:100%;background:var(--up)}
.track b.warn{background:var(--pend)} .track b.bad{background:var(--down)} .track b.flat{background:var(--ink-3)}
.life small{font-family:var(--mono);font-size:11.5px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.life small.warn{color:var(--pend)} .life small.bad{color:var(--down)}
.used{font-family:var(--mono);font-size:12px;color:var(--ink-2);font-variant-numeric:tabular-nums}
.used.never{color:var(--ink-3)}
.right{text-align:right}
/* ---------- modal ---------- */
.stage{margin-top:44px;padding-top:28px;border-top:1px dashed var(--rule)}
.stage h2{font-size:16px}
.stage p.note{color:var(--ink-3);font-size:13px;margin-top:4px;max-width:60ch}
.scrim{margin-top:18px;background:rgba(4,16,31,.72);border:1px solid var(--rule-soft);border-radius:var(--r);padding:28px 16px;display:flex;justify-content:center}
.modal{width:100%;max-width:640px;background:var(--panel);border:1px solid var(--rule);border-radius:var(--r);box-shadow:0 1px 0 rgba(0,0,0,.35),0 20px 44px -26px rgba(0,0,0,.85);overflow:hidden}
.mhead{padding:16px 20px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:center}
.mhead h3{font-size:15px}
.mhead .hint{margin-top:2px}
.mbody{padding:20px;display:flex;flex-direction:column;gap:20px}
.mfoot{padding:14px 20px;border-top:1px solid var(--rule);display:flex;justify-content:flex-end;gap:8px;background:var(--panel-2)}
label.f{display:flex;flex-direction:column;gap:6px}
label.f>span{font-size:13px;font-weight:500}
label.f em{font-style:normal;font-size:12px;color:var(--ink-3);font-weight:400}
input[type=text],select{font:inherit;background:var(--well);border:1px solid var(--rule);color:var(--ink);border-radius:var(--r);padding:9px 11px;width:100%}
input[type=text]::placeholder{color:var(--ink-3)}
.two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
/* scope matrix in the modal — one grid, not 9 cards */
.matrix{border:1px solid var(--rule);border-radius:var(--r);overflow:hidden}
.mx{display:grid;grid-template-columns:1fr 64px 64px;align-items:center}
.mx.head{background:var(--panel-2);border-bottom:1px solid var(--rule);font-family:var(--mono);font-size:11px;color:var(--ink-3)}
.mx>*{padding:7px 12px}
.mx.head>*:not(:first-child),.mx>label{text-align:center}
.mx+.mx{border-top:1px solid var(--rule-soft)}
.mx>b{font-weight:500;font-size:13px}
.mx>b small{display:block;color:var(--ink-3);font-size:11.5px;font-weight:400}
.mx label{display:flex;justify-content:center;cursor:pointer}
input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);background:var(--well);cursor:pointer}
.mxfoot{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 12px;background:var(--panel-2);border-top:1px solid var(--rule);font-size:12px;color:var(--ink-3)}
.linky{background:none;border:0;font:inherit;color:var(--accent);cursor:pointer;padding:0}
.linky:hover{color:var(--accent-hover);text-decoration:underline}
/* live preview line — what this key will be able to do, in one sentence */
.preview{background:var(--well);border:1px solid var(--rule-soft);border-radius:var(--r);padding:11px 13px;font-family:var(--mono);font-size:12px;color:var(--ink-2);line-height:1.7}
.preview b{color:var(--ink);font-weight:500}
.preview .cap{color:var(--pend)}
/* reveal panel */
.reveal{display:flex;flex-direction:column;gap:14px}
.warnbar{border:1px solid rgba(214,166,63,.35);background:rgba(214,166,63,.1);color:var(--pend);border-radius:var(--r);padding:9px 12px;font-size:13px}
.secret{display:flex;gap:0;align-items:stretch;border:1px solid var(--rule);border-radius:var(--r);overflow:hidden;background:var(--well)}
.secret code{flex:1;font-family:var(--mono);font-size:13px;padding:11px 12px;overflow-x:auto;white-space:nowrap;color:var(--ink)}
.secret button{border:0;border-left:1px solid var(--rule);background:var(--panel-2);color:var(--ink);font:inherit;font-size:13px;padding:0 16px;cursor:pointer}
.secret button:hover{background:var(--rule)}
dl.meta{display:grid;grid-template-columns:88px 1fr;gap:8px 14px;font-size:13px;align-items:baseline}
dl.meta dt{color:var(--ink-3)}
/* ---------- narrow: the ledger stops being a table ---------- */
@media (max-width:900px){
.wrap{padding:24px 16px 72px}
.masthead{align-items:flex-start;gap:16px}
.masthead>.btn{width:100%;justify-content:center}
.posture{grid-template-columns:1fr 1fr}
.two{grid-template-columns:1fr}
/* Each key becomes a stacked record. The header row is gone, so every
cell carries its own label — an unlabelled date under an unlabelled
scope list is unreadable once the columns are gone. */
.lhead{display:none}
.lrow{grid-template-columns:1fr;gap:12px;align-items:stretch;padding:16px 16px 12px;position:relative}
.lrow>[data-label]::before{content:attr(data-label);display:block;font-family:var(--mono);font-size:11px;letter-spacing:.06em;color:var(--ink-3);margin-bottom:6px}
.name{padding-right:88px}
.scopes{overflow-x:auto;flex-wrap:nowrap;padding-bottom:2px;-webkit-overflow-scrolling:touch}
.scope{flex:0 0 auto}
.right{position:absolute;top:12px;right:12px;text-align:right}
.btn-danger{border-color:var(--rule);padding:7px 12px}
}
@media (max-width:520px){
h1{font-size:22px}
.posture{grid-template-columns:1fr}
.posture div{flex-direction:row;align-items:baseline;gap:10px}
.posture .n{font-size:16px;min-width:2ch}
/* The spacer and the hint were competing with the segment for one row,
squeezing the buttons below their own labels. Stack instead. */
.filters{flex-direction:column;align-items:stretch}
.filters .spacer{display:none}
.filters .seg{width:100%}
.filters .seg button{flex:1;white-space:nowrap;padding:9px 8px}
.scrim{padding:16px 10px}
.mbody{padding:16px;gap:16px}
.mhead,.mfoot{padding:14px 16px}
.mfoot{flex-direction:column-reverse}
.mfoot .btn{width:100%;justify-content:center}
.mx{grid-template-columns:1fr 54px 54px}
.mx>*{padding:9px 10px}
.mxfoot{flex-direction:column;align-items:flex-start;gap:6px}
/* Copy has to stay reachable without scrolling the secret sideways first. */
.secret{flex-direction:column}
.secret button{border-left:0;border-top:1px solid var(--rule);padding:11px 16px}
dl.meta{grid-template-columns:1fr;gap:3px}
dl.meta dt{margin-top:8px}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
<div class="wrap">
<header class="masthead">
<div>
<h1>API keys</h1>
</div>
<button class="btn btn-primary" onclick="document.getElementById('create').scrollIntoView({behavior:'smooth'})">Create key</button>
</header>
<section class="posture" aria-label="Key posture">
<div><span class="n">6</span><span class="l">keys in this instance</span></div>
<div><span class="n warn">2</span><span class="l">expire within 7 days</span></div>
<div><span class="n bad">1</span><span class="l">never expires</span></div>
<div><span class="n dim">2</span><span class="l">unused since issue</span></div>
</section>
<div class="filters">
<div class="seg" role="group" aria-label="Whose keys">
<button type="button" aria-pressed="false">My keys</button>
<button type="button" aria-pressed="true">All keys</button>
</div>
<div class="spacer"></div>
<span class="hint">Instance policy caps new keys at 90 days.</span>
</div>
<section class="ledger" aria-label="API keys">
<div class="lrow lhead">
<span>key / holder</span><span>scopes</span><span>lifetime</span><span>last call</span><span></span>
</div>
<div class="lrow">
<div class="name">
<b>gitea-ci-deploy</b>
<span class="fingerprint">vt_9f2c…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill admin"><span class="dot"></span>admin</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:71%"></b></div>
<small>64 days left · 12 Nov</small>
</div>
<span class="used" data-label="last call">4 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>eso-cluster-prod</b>
<span class="fingerprint">vt_41ab…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>secrets</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:6%"></b></div>
<small class="warn">5 days left · 13 Sep</small>
</div>
<span class="used" data-label="last call">22 minutes ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>status-page-embed</b>
<span class="fingerprint">vt_c70e…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>status</span><i>r</i></span>
<span class="scope r"><span>monitors</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b style="width:88%"></b></div>
<small>318 days left · 23 Jul 2027</small>
</div>
<span class="used" data-label="last call">3 days ago</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>patch-tuesday-runner</b>
<span class="fingerprint">vt_2d55…</span>
<span class="who">ops@hostxtra.co.uk · <span class="pill owner"><span class="dot"></span>owner</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope rw"><span>vulns</span><i>rw</i></span>
<span class="scope rw"><span>workloads</span><i>rw</i></span>
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="flat" style="width:100%"></b></div>
<small>No expiry · issued before the 90-day cap</small>
</div>
<span class="used" data-label="last call">Yesterday</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>laptop-scratch</b>
<span class="fingerprint">vt_86f1…</span>
<span class="who">priya@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes"><span class="scope none"><span>no scopes granted</span></span></div>
<div class="life" data-label="lifetime">
<div class="track"><b class="bad" style="width:0%"></b></div>
<small class="bad">Expired 2 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
<div class="lrow">
<div class="name">
<b>terraform-plan-readonly</b>
<span class="fingerprint">vt_0b3d…</span>
<span class="who">joe@hostxtra.co.uk · <span class="pill"><span class="dot"></span>member</span></span>
</div>
<div class="scopes" data-label="scopes">
<span class="scope r"><span>servers</span><i>r</i></span>
<span class="scope r"><span>keys</span><i>r</i></span>
</div>
<div class="life" data-label="lifetime">
<div class="track"><b class="warn" style="width:2%"></b></div>
<small class="warn">2 days left · 10 Sep</small>
</div>
<span class="used never" data-label="last call">Never used</span>
<div class="right"><button class="btn btn-danger">Revoke</button></div>
</div>
</section>
<!-- ============ create modal, state 1 ============ -->
<section class="stage" id="create">
<h2>Create key</h2>
<p class="note">One dialog, three decisions in the order that matters: who the key is, what it may call, how long it lives. The preview line is the key read back as a sentence before it exists.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Create key">
<div class="mhead">
<div>
<h3>Create key</h3>
<p class="hint">Shown once. Copy it before you close.</p>
</div>
<span class="pill"><span class="dot"></span>your role: admin</span>
</div>
<div class="mbody">
<div class="two">
<label class="f">
<span>Name <em>what will use it</em></span>
<input type="text" value="gitea-ci-deploy">
</label>
<label class="f">
<span>Role <em>capped at yours</em></span>
<select><option>admin</option><option>member</option></select>
</label>
</div>
<div>
<label class="f" style="margin-bottom:8px"><span>Scopes <em>write already covers read</em></span></label>
<div class="matrix">
<div class="mx head"><span>resource</span><span>read</span><span>write</span></div>
<div class="mx"><b>servers<small>fleet list, inventory, agent updates</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>workflows<small>steps, runs, logs</small></b><label><input type="checkbox" checked></label><label><input type="checkbox" checked></label></div>
<div class="mx"><b>secrets<small>vault groups and values</small></b><label><input type="checkbox" checked></label><label><input type="checkbox"></label></div>
<div class="mx"><b>keys<small>SSH keys and assignments</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>monitors<small>checks, incidents, uptime</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mx"><b>vulns<small>findings and rescans</small></b><label><input type="checkbox"></label><label><input type="checkbox"></label></div>
<div class="mxfoot">
<span>3 of 9 resources · 5 scopes</span>
<span><button class="linky" type="button">Read-only everywhere</button> · <button class="linky" type="button">Clear all</button></span>
</div>
</div>
</div>
<label class="f">
<span>Expires <em>this instance caps new keys at 90 days</em></span>
<select><option>90 days — 7 December 2026</option><option>60 days</option><option>30 days</option><option disabled>365 days (over the cap)</option><option disabled>Never (over the cap)</option></select>
</label>
<p class="preview">
<b>gitea-ci-deploy</b> acts as <b>admin</b>, may <b>read and write</b> servers and workflows,
<b>read</b> secrets, and stops working on <b class="cap">7 December 2026</b>.
</p>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-primary">Create key</button>
</div>
</div>
</div>
</section>
<!-- ============ create modal, state 2 ============ -->
<section class="stage">
<h2>After it is created</h2>
<p class="note">The secret is unrecoverable once this closes, so Copy is the primary action and the summary confirms what was granted without a second trip to the ledger.</p>
<div class="scrim">
<div class="modal" role="dialog" aria-label="Key created">
<div class="mhead">
<div>
<h3>gitea-ci-deploy is ready</h3>
<p class="hint">Vantage stores only a hash of this value.</p>
</div>
</div>
<div class="mbody reveal">
<div class="warnbar">This is the only time the key is shown. Copy it into your CI secret store now.</div>
<div class="secret">
<code>vt_9f2c4b71ae03d85f6c19bb27e4a0d3f58c62719ad4be05f3c8a1d7602b94ef11</code>
<button type="button">Copy</button>
</div>
<dl class="meta">
<dt>Role</dt><dd>admin</dd>
<dt>Scopes</dt>
<dd class="scopes">
<span class="scope rw"><span>servers</span><i>rw</i></span>
<span class="scope rw"><span>workflows</span><i>rw</i></span>
<span class="scope r"><span>secrets</span><i>r</i></span>
</dd>
<dt>Expires</dt><dd>7 December 2026 · 90 days</dd>
<dt>Use it</dt><dd><code style="font-family:var(--mono);font-size:12px;color:var(--ink-2)">curl -H "Authorization: Bearer vt_…" https://acme.vantage.example/api/servers</code></dd>
</dl>
</div>
<div class="mfoot">
<button class="btn btn-ghost">Done</button>
<button class="btn btn-primary">Copy key</button>
</div>
</div>
</div>
</section>
<!-- ============ empty state ============ -->
<section class="stage">
<h2>When there are no keys</h2>
<div class="scrim" style="padding:40px 16px">
<div style="max-width:420px;text-align:center;display:flex;flex-direction:column;gap:10px;align-items:center">
<span style="font-family:var(--mono);font-size:13px;color:var(--ink-3);border:1px dashed var(--rule);border-radius:var(--r);padding:6px 12px">vt_ · nothing issued yet</span>
<b style="font-size:15px">You have no API keys.</b>
<p style="color:var(--ink-2);font-size:13px">Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never outranks your own role.</p>
<button class="btn btn-primary">Create your first key</button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,378 @@
# API Keys Page Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild `/tokens` — the API keys page and its create dialog — around what an operator actually needs to decide: which credentials are about to expire, what each one can reach, and whether a new one is over-granted. The current page is a seven-column table where every column is a bare string.
**Reference mockup:** `docs/superpowers/plans/2026-09-08-api-keys-redesign-mockup.html`. Open it in a browser. It is the visual contract for this plan: the posture strip, the record layout, the lifetime bar, the scope matrix and both dialog states are all drawn there, in the app's own tokens and font stacks. Where this plan and the mockup disagree, the plan wins — the mockup carries example data and static markup, not logic.
**Tech Stack:** Next.js 16 App Router, React 18, Tailwind 3 (tokens only, no hex), TanStack Query. No new dependencies.
**Existing code:** the whole page is one 487-line file, `web/components/apikeys/ApiKeysPanel.tsx`, rendered by `web/app/(app)/tokens/page.tsx`.
## Global Constraints
- **No component may carry a hex value.** Every colour comes from the Tailwind token map (`accent`, `danger`, `warning`, `success`, `text-primary/secondary/tertiary`, `surface`, `surface-2`, `well`, `border`). This is a repository-wide rule, not a preference for this page — see the Frontend section of `CLAUDE.md`.
- **`web/` is dark only.** Do not add a light variant or a theme toggle.
- **State never reads by colour alone.** Every coloured element in the mockup also carries a text label — an amber lifetime bar always sits above the words "5 days left".
- **There is no test runner in `web/`.** Verification for each task is `npm run lint` and `npm run build` from `vantage-app/web`, plus a stated browser check. Pure logic goes in `web/lib/` so it is at least readable in isolation.
- **The API is the boundary; the UI is the courtesy.** Nothing here may be the only thing enforcing a rule. Disabled expiry options, hidden MCP scopes and the role cap are all mirrors of server behaviour that already exists.
- Conventional commits (`feat:`, `refactor:`, `fix:`), one per task.
- Branch: this work is UI-only and independent of the MCP server tasks, but it **collides with them in one file** — read the next section before starting.
## Relationship to the MCP server plan
`docs/superpowers/plans/2026-09-08-mcp-server.md` is in progress on branch `feat/mcp-server`. Tasks 15 are committed: the `mcp` scope resource exists, and `api_tokens.tag_selector` is modelled, accepted at creation (`POST /api/tokens`) and enforced at the server-resolution chokepoints. Tasks 615 are not started.
**That plan's Task 12 rewrites the same file this plan rewrites**, and its file list is stale — it names `web/app/(app)/settings/`, but the page moved to `web/app/(app)/tokens/` with its body in `web/components/apikeys/ApiKeysPanel.tsx`. Two plans editing one 500-line component from opposite ends is a guaranteed conflict.
Resolution, and it is a decision this plan makes deliberately: **this plan absorbs MCP Task 12 steps 2, 3, 4 and 5** — the tag selector field, the MCP scope gating, the tag chip in the list, and the agent access panel. They are built here, on the redesigned surfaces, because a tag selector is a field in the create dialog and a tag chip is a column in the ledger, and both are cheaper to design once than to design and then redesign.
What stays with MCP Task 12: **step 1 only**, the `Agent Access (MCP)` row on `settings/license/page.tsx`, which is a different file and a different page.
Sequencing:
- Run this plan **on `feat/mcp-server`, after MCP Task 11**, so `license.features.mcp` and the `mcp:*` scopes exist when Tasks 6 and 7 below need them; or
- run it on its own branch **stopping after Task 5**, and land Tasks 6 and 7 later once MCP merges.
Tasks 6 and 7 are written to be skippable and are marked so. Nothing in Tasks 15 depends on MCP.
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.
---
### Task 1: The lifetime model
The redesign's one visual idea is that a key's expiry is a bar, not a date — how much of its issued life is left, coloured by urgency. That calculation is the only real logic on the page, so it goes in a module of its own rather than inline in a cell.
**Files:**
- Create: `web/lib/keyLifetime.ts`
- Modify: none yet
**Interfaces:**
- Produces: `keyLifetime(token, now)` returning `{ state, remainingPct, label, outsidePolicy }`, consumed by Tasks 2 and 3.
- [ ] **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:
```ts
import type { ApiToken } from "@/lib/api";
export type LifetimeState = "healthy" | "soon" | "expired" | "eternal";
export type Lifetime = {
state: LifetimeState;
/** 0100, the share of the token's issued life still to run. `eternal` is 100. */
remainingPct: number;
/** e.g. "64 days left · 12 Nov", "Expired 2 Sep", "No expiry". */
label: string;
/** True when the instance cap has tightened since this token was issued. */
outsidePolicy: boolean;
};
```
Rules the implementation must honour:
- `soon` is seven days or fewer remaining — the same `SEVEN_DAYS_MS` threshold the current file already uses. Keep the constant here and delete it there.
- `remainingPct` is measured against the token's **own** issued span (`created_at``expires_at`), not against the instance cap. A 30-day key at day 15 is half gone; a 365-day key at day 15 is barely started. Clamp to 0100, and guard the zero-length span (`created_at === expires_at`) so it cannot divide by zero.
- A token with no `expires_at` is `eternal`, drawn full-width and grey. It is not `healthy` — "runs forever" is the state the posture strip counts as a risk.
- `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**
From `vantage-app/web`: `npx tsc --noEmit`
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add web/lib/keyLifetime.ts
git commit -m "feat: model an api key's remaining lifetime as a single value"
```
---
### Task 2: Split the panel into components
`ApiKeysPanel.tsx` is 487 lines holding a page, a table, two dialogs and a form. Every later task in this plan edits it. Split first, or each of them edits the same file and the diffs stop being reviewable.
**Files:**
- Create: `web/components/apikeys/ScopeChips.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/CreateKeyDialog.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
**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**
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`.
Keep every explanatory comment with the code it explains. Those comments are the record of why `write` implies `rw` in a chip and why Copy outranks Done, and they are worth more than the lines they sit above.
`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**
```bash
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**
```bash
git add web/components/apikeys/
git commit -m "refactor: split the api keys panel into ledger, chips, lifetime and dialog"
```
---
### Task 3: The ledger
Replace the seven-column table with the record layout from the mockup: identity (name, `vt_` hint, holder, role) in one column, then scopes, lifetime and last call.
**Files:**
- Modify: `web/components/apikeys/KeyLedger.tsx`, `web/components/apikeys/LifetimeBar.tsx`, `web/components/apikeys/ScopeChips.tsx`
**Interfaces:**
- Consumes: `keyLifetime` (Task 1).
- [ ] **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>`**
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.
Keep the header row as a mono, tracked-out strip on `surface-2`. Keep the hover fill. Keep the owner column conditional on `showAll` — but fold it **into** the identity column as a third line rather than adding a fifth grid column, exactly as the mockup does. `showAll` then changes what a record says, not how the page is laid out.
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**
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**
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.
Below `520px`: the posture strip goes single-column, the filter segment goes full width with its hint on its own line, and the dialog footer stacks with the primary button on top.
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**
`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**
```bash
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**
```bash
git add web/components/apikeys/
git commit -m "feat: redesign the api key list as a ledger with lifetime bars"
```
---
### Task 4: The posture strip
Four counts above the list, answering "is anything wrong here" before the operator reads a single row.
**Files:**
- Create: `web/components/apikeys/KeyPosture.tsx`
- Modify: `web/components/apikeys/ApiKeysPanel.tsx`
- [ ] **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.**
"Never used" is `last_used_at == null`. It is muted rather than coloured: an unused key is a cleanup candidate, not an incident.
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**
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**
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**
```bash
git add web/components/apikeys/
git commit -m "feat: summarise key posture above the ledger"
```
---
### Task 5: The create dialog
Name and role side by side, scopes as one matrix instead of nine mini-cards, an expiry select that names the resolved date, and a preview line that reads the key back as a sentence before it exists.
**Files:**
- Modify: `web/components/apikeys/CreateKeyDialog.tsx`
- Create: `web/components/apikeys/ScopeMatrix.tsx`
- [ ] **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.
Each row carries a one-line description under the resource name ("fleet list, inventory, agent updates"). Those strings are UI copy with no server counterpart, so keep them in one exported record in this file, keyed by resource, and fall back to no description for an unknown key rather than rendering `undefined`.
The footer carries the running count ("3 of 9 resources · 5 scopes") and two bulk actions: **Read-only everywhere** and **Clear all**.
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**
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**
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**
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**
```bash
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**
```bash
git add web/components/apikeys/
git commit -m "feat: rebuild the create key dialog around a scope matrix and a preview"
```
---
### Task 6: Tag restriction — absorbs MCP Task 12 steps 2 and 4
**Requires MCP Tasks 35, which are already committed on `feat/mcp-server`.** Skip this task entirely on a branch that does not have them; `tag_selector` will be rejected by a server without them.
**Files:**
- 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**
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**
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.
Send `tag_selector` omitted or `{}` when unrestricted. **This field is not licence-gated** — tag scoping ships useful on its own and is shown to everyone.
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**
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**
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**
```bash
git add web/lib/api.ts web/components/apikeys/
git commit -m "feat: restrict an api key to tagged servers from the create dialog"
```
---
### Task 7: Agent access — absorbs MCP Task 12 steps 3 and 5
**Requires MCP Tasks 611 (the endpoint itself) and the `mcp` licence feature.** Skip on a branch without them.
**Files:**
- 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**
`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**
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**
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**
```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**
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:`.
---
### Task 8: Documentation
**Files:**
- Modify: `vantage-docs/docs/reference/api-tokens.md`
- [ ] **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**
```bash
cd vantage-docs
git add docs/reference/api-tokens.md
git commit -m "docs: describe the redesigned api keys page"
```
---
## Verification checklist
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.
- [ ] `/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.
- [ ] A member (not owner or admin) sees the page, sees only their own keys, and sees no `All keys` toggle.
- [ ] Revoke still works, still names the key in its confirmation and its toast, and still shows the server's error inline on failure.
- [ ] The plaintext key is still shown exactly once, and closing the dialog after a reveal still invalidates the list.
+72
View File
@@ -0,0 +1,72 @@
import type { ApiToken } from "@/lib/api";
/**
* How much of an API key's issued life is left, as one value.
*
* The list draws expiry as a bar rather than a date, so the calculation behind
* it stopped being a formatting detail of one cell and became the only real
* logic on the page. It lives here so it can be read in one sitting, and so the
* ledger and the posture strip cannot disagree about what "expiring soon" means.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * DAY_MS;
export type LifetimeState = "healthy" | "soon" | "expired" | "eternal";
export type Lifetime = {
state: LifetimeState;
/** 0100, the share of the token's issued life still to run. `eternal` is 100. */
remainingPct: number;
/** e.g. "64 days left · 12 Nov", "Expired 2 Sep", "No expiry". */
label: string;
/** True when the instance cap has tightened since this token was issued. */
outsidePolicy: boolean;
};
function shortDate(d: Date) {
return d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: d.getFullYear() === new Date().getFullYear() ? undefined : "numeric" });
}
/**
* `capDays` is the instance's current `api_token_max_days`, 0 when unset. It is
* never applied retroactively — a token issued before the cap tightened keeps
* working, and `outsidePolicy` is a prompt to rotate rather than a failure of
* any kind. Copy built on this flag must not imply the key has stopped working.
*/
export function keyLifetime(token: ApiToken, capDays = 0, now = Date.now()): Lifetime {
const outsidePolicy =
capDays > 0 && (!token.expires_at || new Date(token.expires_at).getTime() > now + capDays * DAY_MS);
if (!token.expires_at) {
// Not "healthy": a key that runs forever is the state the posture strip
// counts as a risk, so it gets its own name rather than the good one.
return { state: "eternal", remainingPct: 100, label: "No expiry", outsidePolicy };
}
const expiresAt = new Date(token.expires_at).getTime();
const issuedAt = new Date(token.created_at).getTime();
const remainingMs = expiresAt - now;
if (remainingMs <= 0) {
return { state: "expired", remainingPct: 0, label: `Expired ${shortDate(new Date(expiresAt))}`, outsidePolicy };
}
// Measured against the token's own issued span, not against the instance
// cap: a 30-day key at day 15 is half gone, a 365-day key at day 15 is
// barely started, and one bar has to say which. A zero-length span is not
// reachable through the UI but is cheap to survive.
const span = expiresAt - issuedAt;
const rawPct = span > 0 ? (remainingMs / span) * 100 : 100;
const remainingPct = Math.min(100, Math.max(0, rawPct));
const days = Math.ceil(remainingMs / DAY_MS);
const label = `${days} day${days === 1 ? "" : "s"} left · ${shortDate(new Date(expiresAt))}`;
return {
state: remainingMs <= SEVEN_DAYS_MS ? "soon" : "healthy",
remainingPct,
label,
outsidePolicy,
};
}