/* 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 (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 * 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 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//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). * * 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. * * Phase 76 (task 06) — shell view module: the top-level boot is * `export async function mount(root)` — root is the view's * `
`, 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. * * 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"); const gateEl = root.querySelector("#tokens-gate"); const statusEl = root.querySelector("#tokens-status"); // The create row (label + 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"); /* 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. 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) { 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); 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 } tr.appendChild(statusTd); const actionsTd = document.createElement("td"); actionsTd.className = "tokens-actions-cell"; if (!tok.revoked) { actionsTd.appendChild(makeRevokeControl(tok, tr)); } tr.appendChild(actionsTd); return tr; } /* 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//revoke → the row re-renders Revoked (+ 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//revoke (204 — idempotent server-side) → the row STAYS (it is not removed) and re-renders Revoked, and the live region gets `Revoked "