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`.
709 lines
32 KiB
JavaScript
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();
|
|
}
|