Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
417 lines
17 KiB
JavaScript
417 lines
17 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 (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/<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).
|
||
*
|
||
* 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
|
||
* `<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.
|
||
*
|
||
* 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/<id>/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/<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. */
|
||
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) {
|
||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
||
announce("That token was already revoked.");
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
announce(`Couldn't revoke "${tok.label}" — try again.`);
|
||
restoreRevoke();
|
||
return;
|
||
}
|
||
row.replaceWith(makeRow({ ...tok, revoked: true }));
|
||
announce(`Revoked "${tok.label}".`);
|
||
}
|
||
|
||
/* The empty-state row reappears exactly when there is nothing else
|
||
in the tbody (the empty row itself ships in the tbody, hidden). */
|
||
function showEmptyState() {
|
||
if (!tbody) return;
|
||
tbody.replaceChildren(emptyRow);
|
||
if (emptyRow) emptyRow.hidden = false;
|
||
}
|
||
|
||
/* 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).
|
||
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.remove();
|
||
}
|
||
}
|
||
if (emptyRow) emptyRow.hidden = true;
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/tokens");
|
||
} catch {
|
||
announce("Couldn't load tokens — is the app reachable?");
|
||
showEmptyState();
|
||
return false;
|
||
}
|
||
if (!r.ok) {
|
||
announce("Couldn't load tokens — try again.");
|
||
showEmptyState();
|
||
return false;
|
||
}
|
||
const { tokens } = await r.json();
|
||
if (!tokens.length) {
|
||
showEmptyState();
|
||
return true;
|
||
}
|
||
for (const tok of tokens) {
|
||
tbody.appendChild(makeRow(tok));
|
||
}
|
||
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
|
||
(retryable) and lands the error line. */
|
||
async function generateToken() {
|
||
const label = (labelInput ? labelInput.value : "").trim() || "token";
|
||
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 + table 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 (tableWrap) tableWrap.hidden = true;
|
||
return;
|
||
}
|
||
if (gateEl) gateEl.hidden = true;
|
||
if (createRow) createRow.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());
|
||
}
|
||
started = true;
|
||
loadTokens();
|
||
}
|