feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
/* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user