phase: 101_tokens_page_overhaul
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s

All gates verified green — no defects found, nothing to fix. Final report:

## Phase 101 — final verification pass (all tasks already complete; verified, not re-implemented)

**Verified against the working tree** (task files 01–04 present in `complete/`):
- Active/revoked table split (Status column gone, section hidden while 0 revoked, per-table search with query state surviving re-renders) in `frontend/index.html` + `tokens.js`
- `generateToken()` refuses blank/whitespace names (`Give the token a name first.`, no request); `|| "token"` fallback deleted
- Atomic rotation: `regenerate_token` service + `POST /api/tokens/{id}/regenerate` (201 new-plaintext-once / 404 / 409 / router-wide 403) + `TokenListItem.revoked_at` (D5)
- Regenerate two-step confirm UI + CSS (`.token-regenerate`, neutral hover, no new hue); A4 pins intact

**Test / lint / coverage results:**
- `uv run pytest` → **2065 passed**
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- `uv run pytest tests/e2e/test_tokens_page.py -v --no-cov` → **4 passed** (isolation, DB up)
- Regression, each in isolation: `test_api_tokens.py` **9 passed**, `test_admin_auth.py` **6 passed**, `test_shared_header.py` **6 passed**, `test_theme_semantic_completion.py` **8 passed** (its revoked-pill pin was correctly re-scoped to the revoked table in this phase)

**Completion criteria:** 1 ✓ split+search (E2E 1–2) · 2 ✓ required name (E2E 3 + source pin) · 3 ✓ rotation end-to-end, old token refused at gate (E2E 4 + API 404/409 pinned) · 4 ✓ A4 holds (list carries no plaintext/hashes) · 5 ✓ suite/coverage/lint green · 6 ✓ E2E + regressions green in isolation · 7 commit left to the harness per executor rules (all changes uncommitted in the working tree)

**Deviations:** none. Next pending phase: `98_sync_summary_visibility`.
This commit is contained in:
2026-09-12 15:16:02 -04:00
parent 58e9d94cff
commit 4dbac1660a
32 changed files with 2321 additions and 150 deletions
+54 -2
View File
@@ -3167,14 +3167,66 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state row: the muted centered message at full table width
(the .history-empty-row language, inline in the table). */
/* Regenerate (phase 101 task 03, D2): the .token-revoke's structural
twin — the SAME size / border / radius / focus, the transparent
fill + ink-soft text — but the NEUTRAL action's hover
(brand-soft / brand-ink), NOT the revoke's error hover: a
rotation is a hand-out of a successor, not a deletion (no new
hue — the phase-92 monochrome invariant). The confirm pair
reuses the .history-confirm-* classes unchanged (the pair CSS
above). :disabled mirrors the revoke button's rule (the
in-flight state, while the rotation POST is out). */
.token-regenerate {
min-height: 44px;
padding: 0.35rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.token-regenerate:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); }
.token-regenerate:disabled { opacity: 0.5; cursor: wait; }
/* Empty-state + search no-match rows (phase 101): the muted centered
message at full table width (the .history-empty-row language,
inline in the table) — CLASS-based, so both the active table's
#tokens-empty-row / #tokens-no-match-row and the revoked table's
#tokens-revoked-no-match-row share it. */
.tokens-empty-row td {
padding: 2.25rem 1rem;
text-align: center;
color: var(--ink-soft);
font-style: italic;
}
/* Phase 101 (task 02, D4): the per-table live label search — the
house input surface (the #token-label / archive-upload input
family: --line hairline, --surface fill, ink text — AA pairs, no
new hue), full width, the ≥44px target, the global :focus-visible
ring (no custom focus rule — the house pattern). */
.token-search {
width: 100%;
min-height: 44px;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 0.9rem;
}
/* The revoked section's sub-heading (phase 101 D1): the phase-97
.kb-level h2 voice — mono, 1rem, brand-ink (the path voice, AA on
the page background). */
.tokens-revoked-heading {
margin: 1.5rem 0 0.5rem;
font-family: var(--mono);
font-size: 1rem;
color: var(--brand-ink);
}
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
The shell's seventh view (#view-theme): the admin palette + branding
+384 -92
View File
@@ -7,9 +7,12 @@
* Wires the admin-only token endpoints (phase 79 task 02) into the
* view:
*
* • the create row (label + Generate): a BLANK label sends "token"
* (the placeholder documents the fallback; the API's 1–120
* validator is satisfied). POST /api/tokens → 201 — the ONE
* • the create row (name + Generate): a BLANK / whitespace-only
* name is REFUSED client-side (phase 101 D3) — the live region
* reads `Give the token a name first.`, the name input re-focuses,
* and NO request is sent (the server's 422 on a blank/over-long
* label is defense in depth; the old blank-label "token"
* fallback is GONE). On a named generate: POST /api/tokens → 201 — the ONE
* response that carries the plaintext (owner-locked A4) — and the
* plaintext appears EXACTLY ONCE: in the #token-once block's mono
* read-only field, with a Copy (the clipboard; a non-secure http
@@ -18,31 +21,68 @@
* loadTokens() / re-show (and the field is wiped with it) — the
* plaintext is NOT stored anywhere client-side (no localStorage,
* no data attribute), so a re-render can never re-show it;
* • the full-width table (AGENTS.md rule 5): Label (textContent —
* admin-derived, still text) | Created (locale date+time, the full
* ISO in the title) | Last used (locale or "never") | Status — a
* plain em-dash for Active, the rose .stale-pill for Revoked (the
* stale-pill visual language; the cell carries its aria-label in
* BOTH states — WCAG 2.1 AA) | Actions — Revoke, the inline
* TWO-STEP confirm (the history-confirm-* pattern — NO native
* confirm dialog: the first click swaps to "Revoke? [Yes] [No]",
* focus moves to Yes; Yes POSTs /api/tokens/<id>/revoke and the
* row re-renders Revoked + the live region line; No or a failed
* request restores the Revoke button — retryable). Revoked rows
* carry NO action (nothing left to revoke).
* • the SPLIT tables (phase 101 D1 — AGENTS.md rule 5, full width,
* no skinny list): the ACTIVE table on top (Label | Created |
* Last used | Actions — the two-step inline Regenerate BEFORE
* the two-step inline Revoke — phase 101 task 03 / D2) and the
* REVOKED table below
* it (Label | Created | Last used | Revoked — the revoked_at
* date). The Status column is GONE from both: the table's
* position IS the status (an all-active table needs no status; a
* row in the revoked table is a dead token — no actions, nothing
* left to revoke). The revoked SECTION (heading + search + table
* wrap) is hidden while no token is revoked (an empty table is
* noise); the active table keeps its empty-state row;
* • the per-table LIVE label search (phase 101 D4): one type=search
* input per table — a case-insensitive SUBSTRING over the
* rendered rows' label cells, applied on `input` with NO fetch
* (each data row toggles its `hidden`), and RE-APPLIED after
* every loadTokens() (the queries live in module state —
* activeQuery / revokedQuery — and survive re-renders / re-shows,
* the phase-77 contract). Zero visible matches with a non-empty
* query → the per-table no-match row (`No tokens match
* "<query>".` — textContent, the distinct-from-empty-state
* language); an empty query shows every row;
* • the one-click REGENERATE (phase 101 task 03, D2 — rotation,
* atomic): every ACTIVE row carries a Regenerate button BEFORE
* its Revoke (the primary lifecycle action — ONE button starts
* it). The control is a structural mirror of the Revoke control:
* the first click swaps the button, in place, to the house
* two-step confirm (`Regenerate? The current token is revoked.
* [Yes] [No]` — the history-confirm-* classes, focus to Yes),
* and it owns its OWN .tokens-actions wrapper span, so a confirm
* in it never clobbers the Revoke control's. Yes → POST
* /api/tokens/<id>/regenerate (JSON, NO body) — the server
* rotates in ONE transaction: the old row is stamped revoked (it
* lands in the revoked table) and the successor is created under
* the SAME label (it lands in the active table); the 201 body
* ({ id, label, token, created_at }) is the new token's ONLY
* plaintext moment (A4). On 201 the re-entrant loadTokens() runs
* FIRST (the relocation), THEN the shown-once block reveals the
* NEW plaintext — the SAME #token-once block, the value-only
* contract (the plaintext lives in the field's value, never a
* data attribute) — and the live region reads `Regenerated
* "<label>" — copy the new token now; it won't be shown again.`.
* A 404 (the row vanished — revoked AND deleted elsewhere, or a
* stale render) removes the row, re-fetches (the reconciliation),
* and reuses the revoke control's 404 line `That token was
* already revoked.`; a 409 (revoked between render and click)
* re-fetches + the same line; any other failure / network error
* restores the Regenerate button (retryable) with the neutral
* house copy ("is the app reachable?" / "try again.").
*
* Every cell is built with the DOM APIs (textContent) — this file
* never builds HTML (the XSS-safe-by-construction house rule; a full-
* file source pin enforces it).
*
* The whoami gate (phase 19 shared-header module, cached promise):
* • anonymous → the #tokens-gate is shown, the create row + table
* hide, and NO /api/tokens request is made at all (the router
* 403s anonymous — the same request-log contract as the history
* view);
* • admin → the gate hides, the create row + table reveal, and
* `loadTokens()` renders the rows; a 0-row fetch (and a failed
* load) reveals the empty-state row.
* • anonymous → the #tokens-gate is shown, the create row + search
* + tables hide, and NO /api/tokens request is made at all (the
* router 403s anonymous — the same request-log contract as the
* history view);
* • admin → the gate hides, the create row + search + active table
* reveal, and `loadTokens()` renders the rows; a 0-active-row
* fetch (and a failed load) reveals the active empty-state row.
*
* Phase 76 (task 06) — shell view module: the top-level boot is
* `export async function mount(root)` — root is the view's
@@ -68,6 +108,23 @@
* branch, after the whoami gate passes: anonymous shows the gate and
* never fetches.
*
* Phase 101 (tasks 02 + 03) — the tokens page overhaul: the single
* table SPLIT into active + revoked (D1: the table's position IS the
* status, the Status column + the em-dash / .stale-pill are gone),
* the per-table live label search (D4: client-side, query state
* survives re-renders), the required name (D3: a blank name
* generates NOTHING), and the one-click REGENERATE (D2: the rotation
* is atomic server-side — the old row revoked, the successor created
* under the same label — the 201's re-entrant load runs BEFORE the
* once-block reveal; a 404 removes the vanished row + re-fetches, a
* 409 re-fetches — both landing the house line "That token was
* already revoked."; a retryable failure restores the button).
* loadTokens keeps its re-entrant core
* (once-block hidden + wiped, data rows dropped, the fetch, the error
* lines, the return value) and SPLITS the fetched list by
* `tok.revoked` — the server's newest-first order kept per table —
* before re-applying BOTH persistent filters.
*
* The clipboard + inline-fallback helper is tokens.js's OWN ~10-line
* copy (the per-page duplication house style — history.js keeps the
* share link's, app.js the chat page's; no new shared module).
@@ -80,9 +137,23 @@ export async function mount(root) {
const tableWrap = root.querySelector("#tokens-table-wrap");
const tbody = root.querySelector("#tokens-tbody");
const emptyRow = root.querySelector("#tokens-empty-row");
// Phase 101 (task 02, D4): the per-table search inputs + no-match
// rows (the active no-match row ships in #tokens-tbody, hidden; its
// <td> text is JS-filled).
const searchActive = root.querySelector("#token-search-active");
const noMatchRow = root.querySelector("#tokens-no-match-row");
// Phase 101 (task 02, D1): the REVOKED section — the visible
// sub-heading, the section's own search input, the table wrap, the
// tbody, and its no-match row. The section ships hidden;
// setRevokedSectionVisible(n) shows it iff n ≥ 1.
const revokedHeading = root.querySelector("#tokens-revoked-heading");
const searchRevoked = root.querySelector("#token-search-revoked");
const revokedWrap = root.querySelector("#tokens-revoked-wrap");
const revokedTbody = root.querySelector("#tokens-revoked-tbody");
const revokedNoMatchRow = root.querySelector("#tokens-revoked-no-match-row");
const gateEl = root.querySelector("#tokens-gate");
const statusEl = root.querySelector("#tokens-status");
// The create row (label + Generate) — SHIPS hidden (anonymous-safe;
// The create row (name + Generate) — SHIPS hidden (anonymous-safe;
// the admin branch reveals it).
const createRow = root.querySelector("#token-create");
const labelInput = root.querySelector("#token-label");
@@ -93,6 +164,13 @@ export async function mount(root) {
const onceValue = root.querySelector("#token-once-value");
const onceCopy = root.querySelector("#token-once-copy");
/* Phase 101 (task 02, D4): the per-table search queries — module
state that SURVIVES every re-render and re-show: a load never
resets them (it re-applies them), and the input listeners are
the only writers. */
let activeQuery = "";
let revokedQuery = "";
/* Action feedback — the role="status" live region (the "never stale"
contract: every action lands a line here, success or failure
alike). */
@@ -141,12 +219,18 @@ export async function mount(root) {
);
}
/* One row. The Status cell carries the cell-level aria-label in BOTH
states (Active em-dash / the rose Revoked pill — the stale-pill
visual language) so the marker is conveyed without the visual
(WCAG 2.1 AA). The Actions cell carries the two-step Revoke for
active rows only — revoked rows have nothing left to revoke. */
function makeRow(tok) {
/* One row for ONE of the two tables (phase 101 D1): "active" —
Label | Created | Last used | Actions (the two-step inline
Regenerate BEFORE the two-step inline Revoke — phase 101
task 03 / D2: the rotation is the primary lifecycle action), or
"revoked" — Label | Created | Last used | Revoked (the
revoked_at date: locale date+time, full ISO on hover — the
house tokens-date-cell language). The row's very presence in the
revoked table IS the status (the em-dash / .stale-pill column is
gone from both tables); revoked rows carry NO actions. The label
cell (.tokens-label-cell) is the search filter's data source —
applyFilter reads its textContent. */
function makeRow(tok, table) {
const tr = document.createElement("tr");
const labelTd = document.createElement("td");
@@ -171,43 +255,93 @@ export async function mount(root) {
}
tr.appendChild(usedTd);
const statusTd = document.createElement("td");
statusTd.className = "tokens-status-cell";
if (tok.revoked) {
statusTd.setAttribute(
"aria-label",
"Revoked — this token is dead and can no longer sign in",
);
const pill = document.createElement("span");
pill.className = "stale-pill";
pill.title = "Revoked — the token can no longer sign in";
pill.textContent = "Revoked";
statusTd.appendChild(pill);
} else {
statusTd.setAttribute(
"aria-label",
"Active — this token can still be used to sign in",
);
statusTd.textContent = "—"; // the em-dash: active rows' marker
if (table === "revoked") {
const revokedTd = document.createElement("td");
revokedTd.className = "tokens-date-cell";
if (tok.revoked_at) {
revokedTd.title = tok.revoked_at; // full ISO on hover
revokedTd.textContent = fmtDate(tok.revoked_at);
} else {
revokedTd.textContent = "—"; // defensive: the server stamps it
}
tr.appendChild(revokedTd);
return tr;
}
tr.appendChild(statusTd);
const actionsTd = document.createElement("td");
actionsTd.className = "tokens-actions-cell";
if (!tok.revoked) {
actionsTd.appendChild(makeRevokeControl(tok, tr));
}
/* Phase 101 (task 03, D2): Regenerate FIRST (the primary
lifecycle action) — each control owns its OWN .tokens-actions
wrapper span, so a confirm in one never clobbers the other. */
actionsTd.append(
makeRegenerateControl(tok, tr),
makeRevokeControl(tok, tr),
);
tr.appendChild(actionsTd);
return tr;
}
/* The inline two-step Regenerate (phase 101 task 03, D2 — a
structural mirror of makeRevokeControl, the D2 contract): ONE
button starts the rotation, and its first click replaces it, in
place, by the "Regenerate? The current token is revoked. [Yes]
[No]" pair (the history-confirm-* classes — a destructive
rotation deserves the same confirm weight as Revoke); focus
moves to Yes (keyboard-reachable confirm). Yes →
confirmRegenerate (POST /api/tokens/<id>/regenerate — the
atomic rotation; on 201 the re-entrant load runs FIRST, then
the shown-once block reveals the new plaintext). No or a failed
request restores the Regenerate button (retryable). The control
owns its OWN .tokens-actions wrapper span: the Actions cell
hosts two independent confirm scopes side by side (this one +
the Revoke control's) — a swap in one never clobbers the other.
The shipped state IS the Regenerate button. */
function makeRegenerateControl(tok, row) {
const cell = document.createElement("span");
cell.className = "tokens-actions";
const regenBtn = document.createElement("button");
regenBtn.type = "button";
regenBtn.className = "token-regenerate";
regenBtn.setAttribute("aria-label", `Regenerate token: ${tok.label}`);
regenBtn.textContent = "Regenerate";
function restoreRegenerate() {
cell.replaceChildren(regenBtn);
regenBtn.focus(); // focus returns to the (restored) control
}
regenBtn.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Regenerate? The current token is revoked.";
const yes = document.createElement("button");
yes.type = "button";
yes.className = "history-confirm-yes";
yes.textContent = "Yes";
const no = document.createElement("button");
no.type = "button";
no.className = "history-confirm-no";
no.textContent = "No";
yes.addEventListener("click", () =>
confirmRegenerate(tok, row, yes, restoreRegenerate));
no.addEventListener("click", restoreRegenerate);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(regenBtn); // the shipped state IS the Regenerate button
return cell;
}
/* The inline two-step Revoke (the history-confirm-* pattern — NO
native confirm dialog anywhere in this file). The Revoke button is
replaced, in place, by the "Revoke? [Yes] [No]" pair; focus moves
to Yes (keyboard-reachable confirm). Yes → POST
/api/tokens/<id>/revoke → the row re-renders Revoked (+ the live
region line); No or a failed request restores the Revoke button
(retryable). */
/api/tokens/<id>/revoke → the re-entrant load runs (the row
LEAVES the active table and lands in the revoked table below —
D1) + the live region line; No or a failed request restores the
Revoke button (retryable). */
function makeRevokeControl(tok, row) {
const cell = document.createElement("span");
cell.className = "tokens-actions";
@@ -247,12 +381,14 @@ export async function mount(root) {
}
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
idempotent server-side) → the row STAYS (it is not removed) and
re-renders Revoked, and the live region gets `Revoked "<label>".`
A 404 means the row is gone (revoked elsewhere) — re-render it
Revoked and say so. Any other failure or a network error keeps the
row, restores the Revoke button (retryable), and lands the error
line. */
idempotent server-side) → the live region gets
`Revoked "<label>".` and the re-entrant loadTokens() runs: the
row LEAVES the active table and lands in the revoked table (its
server-stamped revoked_at renders in the Revoked cell — D1). A
404 means the token was revoked elsewhere — the same re-load
reconciles the tables and the line says so. Any other failure or
a network error keeps the row, restores the Revoke button
(retryable), and lands the error line. */
async function confirmRevoke(tok, row, yesBtn, restoreRevoke) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
@@ -264,8 +400,8 @@ export async function mount(root) {
return;
}
if (r.status === 404) {
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce("That token was already revoked.");
await loadTokens(); // the row relocates to the revoked table
return;
}
if (!r.ok) {
@@ -273,26 +409,136 @@ export async function mount(root) {
restoreRevoke();
return;
}
row.replaceWith(makeRow({ ...tok, revoked: true }));
announce(`Revoked "${tok.label}".`);
await loadTokens(); // the row relocates to the revoked table (D1)
}
/* The confirmed regenerate (phase 101 task 03, D2): POST
/api/tokens/<id>/regenerate (JSON, NO body) — the server rotates
atomically in ONE transaction: the old row is stamped revoked
(it lands in the revoked table) and the successor is created
under the SAME label (it lands in the active table). The 201
body ({ id, label, token, created_at }) is the new token's ONLY
plaintext moment (A4 — the old plaintext was already one-shot
and is gone). The 201 sequence is pinned: the re-entrant
loadTokens() runs FIRST (the relocation), THEN the shown-once
block reveals the new plaintext in the field's VALUE only (never
a data attribute — the same #token-once block a create uses),
THEN the live region reads the D2 line. A 404 means the row
VANISHED (revoked AND deleted by another admin, or a stale
render) — the row is removed, the re-fetch reconciles both
tables, and the line reuses the revoke control's 404 copy (one
house message for the one common case). A 409 means the row was
revoked between render and click — the re-fetch reconciles +
the same line. Any other failure or a network error keeps the
row, restores the Regenerate button (retryable), and lands the
neutral error line. */
async function confirmRegenerate(tok, row, yesBtn, restoreRegenerate) {
yesBtn.disabled = true; // no double-fire while the request is in flight
let r;
try {
r = await fetch(`/api/tokens/${tok.id}/regenerate`, { method: "POST" });
} catch {
announce(`Couldn't regenerate "${tok.label}" — is the app reachable?`);
restoreRegenerate();
return;
}
if (r.status === 404) {
row.remove(); // the row vanished — the re-fetch reconciles both tables
await loadTokens();
announce("That token was already revoked.");
return;
}
if (r.status === 409) {
await loadTokens(); // the row was revoked between render and click
announce("That token was already revoked.");
return;
}
if (!r.ok) {
announce(`Couldn't regenerate "${tok.label}" — try again.`);
restoreRegenerate();
return;
}
const data = await r.json();
/* D2: the re-entrant load runs FIRST — the old row relocates to
the revoked table, the new row lands in the active one — then
the once-block reveals the new plaintext (value only, A4). */
await loadTokens();
if (onceValue) onceValue.value = data.token;
if (onceBlock) onceBlock.hidden = false;
announce(
`Regenerated "${tok.label}" — copy the new token now; it won't be shown again.`,
);
}
/* The revoked SECTION (heading + search input + table wrap — D1)
shows iff at least one token is revoked: an empty table is noise.
count is the revoked-row count of the last load (0 hides it). */
function setRevokedSectionVisible(count) {
const show = count > 0;
if (revokedHeading) revokedHeading.hidden = !show;
if (searchRevoked) searchRevoked.hidden = !show;
if (revokedWrap) revokedWrap.hidden = !show;
}
/* The empty-state row reappears exactly when there is nothing else
in the tbody (the empty row itself ships in the tbody, hidden). */
in the tbody (the empty row + the no-match row both ship in the
tbody, hidden — a failed load / 0-row fetch restores BOTH). */
function showEmptyState() {
if (!tbody) return;
tbody.replaceChildren(emptyRow);
tbody.replaceChildren(emptyRow, noMatchRow);
if (emptyRow) emptyRow.hidden = false;
if (noMatchRow) noMatchRow.hidden = true;
}
/* One table's LIVE label filter (phase 101 D4) — pure DOM, NO
fetch. The query is trimmed + lowercased for the MATCH; the
no-match copy quotes the ORIGINAL user text. The rendered rows
are the source of truth: the filter reads each data row's label
cell (.tokens-label-cell) textContent — case-insensitive
substring — and toggles row.hidden. The state rows (the
no-match row, the empty-state row) are NEVER data rows. The
no-match row is visible ⟺ the query is non-empty AND zero data
rows are visible — its <td> textContent = `No tokens match
"<query>".` (the query inside the quotes is textContent, never
HTML). An empty query shows every row and hides the no-match
row. */
function applyFilter(targetTbody, targetNoMatchRow, rawQuery) {
if (!targetTbody) return;
const query = (rawQuery ?? "").trim().toLowerCase();
let visible = 0;
for (const tr of targetTbody.querySelectorAll("tr")) {
if (tr === targetNoMatchRow || tr === emptyRow) continue; // state rows
const labelCell = tr.querySelector(".tokens-label-cell");
const label = (labelCell ? labelCell.textContent : "").toLowerCase();
tr.hidden = query !== "" && !label.includes(query);
if (!tr.hidden) visible += 1;
}
if (!targetNoMatchRow) return;
const noMatch = query !== "" && visible === 0;
targetNoMatchRow.hidden = !noMatch;
if (noMatch) {
const td = targetNoMatchRow.querySelector("td");
if (td) td.textContent = `No tokens match "${rawQuery}".`;
}
}
/* GET /api/tokens → render the rows (newest-first — the server's
order). A 0-row fetch shows the empty-state row. Re-entrant (the
phase-77 re-show contract): a re-show re-run must REPLACE the
list, not append a duplicate set — the data rows (every <tr>
EXCEPT the hidden #tokens-empty-row, which the load itself
re-hides / reveals) are dropped before the fetch — and the
once-block HIDDEN + its field wiped (the plaintext is gone: a
re-render can never re-show it).
order). Re-entrant (the phase-77 re-show contract): a re-show
re-run must REPLACE the list, not append a duplicate set — the
data rows (every <tr> EXCEPT the hidden state rows, which the
load itself re-hides / reveals) are dropped before the fetch —
and the once-block HIDDEN + its field wiped (the plaintext is
gone: a re-render can never re-show it).
Phase 101 (D1): the render step SPLITS the fetched list by
`tok.revoked` — the server's newest-first order kept per table:
the active rows fill #tokens-tbody, the revoked rows fill
#tokens-revoked-tbody. The active empty-state row shows iff there
are ZERO active rows (a 0-active fetch with revoked rows shows
the empty active table AND the populated revoked section — both
honest); the revoked SECTION shows iff ≥ 1 revoked row. FINALLY
both persistent filters re-apply (D4 — a re-render never loses
the queries).
A FAILED load announces its line in the live region (the house
copy: "is the app reachable?" / "try again.") and RETURNS the
outcome: true when the fetch settled (a 0-row fetch is a
@@ -303,49 +549,74 @@ export async function mount(root) {
if (onceValue) onceValue.value = "";
if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) {
if (tr !== emptyRow) tr.remove();
if (tr !== emptyRow && tr !== noMatchRow) tr.remove();
}
}
if (revokedTbody) {
for (const tr of revokedTbody.querySelectorAll("tr")) {
if (tr !== revokedNoMatchRow) tr.remove();
}
}
if (emptyRow) emptyRow.hidden = true;
if (noMatchRow) noMatchRow.hidden = true;
let r;
try {
r = await fetch("/api/tokens");
} catch {
announce("Couldn't load tokens — is the app reachable?");
showEmptyState();
setRevokedSectionVisible(0);
return false;
}
if (!r.ok) {
announce("Couldn't load tokens — try again.");
showEmptyState();
setRevokedSectionVisible(0);
return false;
}
const { tokens } = await r.json();
if (!tokens.length) {
showEmptyState();
return true;
/* D1: the split — the newest-first server order kept per table. */
const active = tokens.filter((t) => !t.revoked);
const revoked = tokens.filter((t) => t.revoked);
for (const tok of active) {
tbody.appendChild(makeRow(tok, "active"));
}
for (const tok of tokens) {
tbody.appendChild(makeRow(tok));
for (const tok of revoked) {
if (revokedTbody) revokedTbody.appendChild(makeRow(tok, "revoked"));
}
if (emptyRow) emptyRow.hidden = active.length !== 0;
setRevokedSectionVisible(revoked.length);
/* D4: the persistent queries survive the re-render — BOTH
filters re-apply after the load. */
applyFilter(tbody, noMatchRow, activeQuery);
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
return true;
}
/* Generate: the label comes from #token-label — a BLANK label sends
"token" (the placeholder documents the fallback; the API's 1–120
validator is satisfied). The button runs the §7.4 never-stale
lifecycle: "Generating…" while the POST is in flight, re-enabled
on success AND failure (the finally — a click can never leave it
stuck disabled). On 201 the re-entrant list load runs FIRST (it
hides the once-block — the re-render contract) and THEN the
once-block reveals with the plaintext (the 201 body's token is
the ONE plaintext that exists, A4 — it lives in this closure
until the next loadTokens() hides the block again), the live
region gets the shown-once line, and the label input clears (a
new token is a new hand-out). A failed create keeps the label
/* Generate: the name comes from #token-label — a BLANK /
whitespace-only name is REFUSED client-side (phase 101 D3): the
live region reads `Give the token a name first.`, the name input
re-focuses, and NO request is sent (the server's 422 on a
blank/over-long label stands unchanged — defense in depth; the
old blank-label "token" fallback is DELETED — the name is the
hand-out identity, not an optional decoration). The button runs the §7.4
never-stale lifecycle: "Generating…" while the POST is in flight,
re-enabled on success AND failure (the finally — a click can
never leave it stuck disabled). On 201 the re-entrant list load
runs FIRST (it hides the once-block — the re-render contract) and
THEN the once-block reveals with the plaintext (the 201 body's
token is the ONE plaintext that exists, A4 — it lives in this
closure until the next loadTokens() hides the block again), the
live region gets the shown-once line, and the name input clears
(a new token is a new hand-out). A failed create keeps the label
(retryable) and lands the error line. */
async function generateToken() {
const label = (labelInput ? labelInput.value : "").trim() || "token";
const label = (labelInput ? labelInput.value : "").trim();
if (!label) {
announce("Give the token a name first.");
if (labelInput) labelInput.focus();
return; // refused client-side (D3) — no request is sent
}
if (generateBtn) generateBtn.disabled = true;
if (generateBtn) generateBtn.textContent = "Generating…";
let created = null;
@@ -381,16 +652,20 @@ export async function mount(root) {
* exactly once, via the chat module (app.js) at shell boot. The
* whoami gate reads fetchIsAdmin() — the SAME cached whoami promise
* the header uses (zero extra requests). Anonymous: the gate in,
* the create row + table out — and NO /api/tokens request at all
* (the router 403s anonymous, so the view must never call it). */
* the create row + search + tables out — and NO /api/tokens request
* at all (the router 403s anonymous, so the view must never call
* it). */
if (!(await fetchIsAdmin())) {
if (gateEl) gateEl.hidden = false;
if (createRow) createRow.hidden = true;
if (searchActive) searchActive.hidden = true;
if (tableWrap) tableWrap.hidden = true;
setRevokedSectionVisible(0);
return;
}
if (gateEl) gateEl.hidden = true;
if (createRow) createRow.hidden = false;
if (searchActive) searchActive.hidden = false;
if (tableWrap) tableWrap.hidden = false;
if (onceBlock) onceBlock.hidden = true; // ships hidden; only a 201 reveals it
/* Phase 77: a user-initiated re-show of this already-mounted view
@@ -411,6 +686,23 @@ export async function mount(root) {
if (onceCopy) {
onceCopy.addEventListener("click", () => void copyTokenAction());
}
/* Phase 101 (task 02, D4): the per-table search listeners — armed
in the ADMIN branch only (after the whoami gate, like the refresh
listener): set the module query + applyFilter, NO fetch. The
queries persist across re-renders / re-shows (loadTokens
re-applies them). */
if (searchActive) {
searchActive.addEventListener("input", () => {
activeQuery = searchActive.value;
applyFilter(tbody, noMatchRow, activeQuery);
});
}
if (searchRevoked) {
searchRevoked.addEventListener("input", () => {
revokedQuery = searchRevoked.value;
applyFilter(revokedTbody, revokedNoMatchRow, revokedQuery);
});
}
started = true;
loadTokens();
}