Files
brain-of-reese/frontend/assets/tokens.js
T
ducoterra 4dbac1660a
Build and Push Containers / build-and-push-app (push) Successful in 1m49s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 101_tokens_page_overhaul
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`.
2026-09-12 15:16:02 -04:00

709 lines
32 KiB
JavaScript

/* Brain of Reese — Tokens view (access tokens, phase 79 task 06;
* the phase-76 fold pattern: shell view module).
*
* TODO.md L5 (owner 2026-09-06): "…api tokens that the admin can
* generate and hand out so people can log in to use the app."
*
* Wires the admin-only token endpoints (phase 79 task 02) into the
* view:
*
* • 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
* origin that rejects it gets the inline fallback — the field
* selects itself for Ctrl/Cmd+C). The block hides on the NEXT
* 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 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 + 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
* `<section id="view-tokens">`, and every DOM lookup scopes to root
* (the view ids stay unique across the shell). The router mounts a
* view ONCE (mount-once, hide-forever), so the bindings + state
* survive every switch. The initSharedHeader() call is DROPPED: in
* the shell the shared header boots exactly once, via the chat module
* (app.js) at shell boot. The admin gate keeps fetchIsAdmin() — the
* SAME cached /api/whoami promise header.js exports (zero extra
* requests).
*
* Phase 77 (the re-show refresh contract): the shell router dispatches
* `bor:view-refresh` on the view's section when the user RE-SHOWS an
* already-mounted view (a switch back onto it, a re-click of the
* Tokens nav link, or back/forward) — the first show (the mount) and
* boot never (the mount's own load is the first fetch). This module
* listens on root and re-runs `loadTokens()`, which is re-entrant: a
* re-load drops the data rows (the hidden #tokens-empty-row stays in
* the tbody) before fetching, so the list is REPLACED — never
* duplicated — and it ALSO re-hides the once-block if one was up
* (the plaintext is gone). The listener is armed only in the ADMIN
* 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).
*/
import { fetchIsAdmin } from "./header.js";
export async function mount(root) {
/* ---------- view elements (the view's section, scoped to 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 (name + Generate) — SHIPS hidden (anonymous-safe;
// the admin branch reveals it).
const createRow = root.querySelector("#token-create");
const labelInput = root.querySelector("#token-label");
const generateBtn = root.querySelector("#token-generate");
// The shown-once block — the plaintext lives in the read-only field's
// VALUE only (never a data attribute, never localStorage).
const onceBlock = root.querySelector("#token-once");
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). */
function announce(message) {
if (statusEl) statusEl.textContent = message;
}
function fmtDate(iso) {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
/* Clipboard + the inline fallback (tokens.js's OWN copy — the
per-page duplication house style): a non-secure (http) homelab
origin rejects navigator.clipboard, so the failure path FOCUSES
+ SELECTS the visible once field — the token is right there, ready
for Ctrl/Cmd+C (the field is the fallback surface; unlike the
share-link case there is nothing to render — the plaintext is
already on screen). Returns true when the clipboard took it. */
async function copyTokenToClipboard() {
const token = onceValue ? onceValue.value : "";
if (!token) return true;
try {
await navigator.clipboard.writeText(token);
return true;
} catch {
if (onceValue) {
onceValue.focus({ preventScroll: true });
onceValue.select();
}
return false;
}
}
/* Copy (the once block's button): clipboard → the inline fallback.
The live region lands the outcome either way. */
async function copyTokenAction() {
const copied = await copyTokenToClipboard();
announce(
copied
? "Token copied."
: "The token is selected in the field — press Ctrl/Cmd+C to copy.",
);
}
/* 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");
labelTd.className = "tokens-label-cell";
labelTd.title = tok.label; // full label on hover (the column ellipsizes)
labelTd.textContent = tok.label; // admin-derived — textContent only
tr.appendChild(labelTd);
const createdTd = document.createElement("td");
createdTd.className = "tokens-date-cell";
createdTd.title = tok.created_at; // full ISO on hover
createdTd.textContent = fmtDate(tok.created_at);
tr.appendChild(createdTd);
const usedTd = document.createElement("td");
usedTd.className = "tokens-date-cell";
if (tok.last_used_at) {
usedTd.title = tok.last_used_at; // full ISO on hover
usedTd.textContent = fmtDate(tok.last_used_at);
} else {
usedTd.textContent = "never"; // not used yet (the token-auth stamp)
}
tr.appendChild(usedTd);
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;
}
const actionsTd = document.createElement("td");
actionsTd.className = "tokens-actions-cell";
/* 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 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";
const revokeBtn = document.createElement("button");
revokeBtn.type = "button";
revokeBtn.className = "token-revoke";
revokeBtn.setAttribute("aria-label", `Revoke token: ${tok.label}`);
revokeBtn.textContent = "Revoke";
function restoreRevoke() {
cell.replaceChildren(revokeBtn);
revokeBtn.focus(); // focus returns to the (restored) control
}
revokeBtn.addEventListener("click", () => {
const label = document.createElement("span");
label.className = "history-confirm-text";
label.textContent = "Revoke?";
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", () =>
confirmRevoke(tok, row, yes, restoreRevoke));
no.addEventListener("click", restoreRevoke);
cell.replaceChildren(label, yes, no);
yes.focus(); // the confirm pair takes over the focus
});
cell.appendChild(revokeBtn); // the shipped state IS the Revoke button
return cell;
}
/* The confirmed revoke: POST /api/tokens/<id>/revoke (204 —
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;
try {
r = await fetch(`/api/tokens/${tok.id}/revoke`, { method: "POST" });
} catch {
announce(`Couldn't revoke "${tok.label}" — is the app reachable?`);
restoreRevoke();
return;
}
if (r.status === 404) {
announce("That token was already revoked.");
await loadTokens(); // the row relocates to the revoked table
return;
}
if (!r.ok) {
announce(`Couldn't revoke "${tok.label}" — try again.`);
restoreRevoke();
return;
}
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 + 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, 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). 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
SUCCESS — the empty state is the honest view), false on non-2xx /
network error. */
async function loadTokens() {
if (onceBlock) onceBlock.hidden = true;
if (onceValue) onceValue.value = "";
if (tbody) {
for (const tr of tbody.querySelectorAll("tr")) {
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();
/* 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 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 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();
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;
try {
const r = await fetch("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label }),
});
if (!r.ok) {
announce("Couldn't create the token — try again.");
return;
}
created = await r.json();
} catch {
announce("Couldn't create the token — is the app reachable?");
return;
} finally {
if (generateBtn) generateBtn.disabled = false;
if (generateBtn) generateBtn.textContent = "Generate";
}
/* The re-render load FIRST (the once-block's hider), then the
reveal: the plaintext lands in the field's VALUE only. */
await loadTokens();
if (onceValue) onceValue.value = created.token;
if (onceBlock) onceBlock.hidden = false;
if (labelInput) labelInput.value = "";
announce("Token created — copy it now; it won't be shown again.");
}
/* ---------- view boot (phase 79 task 06) ----------
* The shared header is NOT booted here — in the shell it runs
* 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 + 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
makes the router dispatch bor:view-refresh on the section —
re-load then (loadTokens is re-entrant: the list is replaced AND
the once-block re-hidden — the plaintext is gone). The listener
is armed ONLY here, after the whoami gate passed: anonymous shows
the gate and must never fetch. `started` flips true once the
first loadTokens() is made (below), so the listener can only ever
re-run a load the mount already did. */
let started = false;
root.addEventListener("bor:view-refresh", () => {
if (started) loadTokens();
});
if (generateBtn) {
generateBtn.addEventListener("click", () => void generateToken());
}
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();
}