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:
+41
-5
@@ -265,13 +265,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
fetchIsAdmin,
|
||||
fetchWhoami,
|
||||
initSharedHeader,
|
||||
refreshSteering,
|
||||
announceSteering,
|
||||
} from "./header.js";
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: chips open the same-page modal
|
||||
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the in-app token gate
|
||||
|
||||
/* Phase 77 (task 02): the bor:view-refresh exclusion is deliberate — the in-flight SSE stream and the local conversation must survive every switch (phase 76), so the chat view never listens and never re-fetches on a show. */
|
||||
const messagesEl = document.querySelector("#messages");
|
||||
const emptyState = document.querySelector("#empty-state");
|
||||
const suggestionsEl = document.querySelector("#suggestions");
|
||||
@@ -1830,6 +1832,11 @@ function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
|
||||
const signInLink = document.querySelector("#sign-in-link");
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
let isAdmin = false;
|
||||
// Phase 79 (task 05): the authenticated role — admin OR token user.
|
||||
// The auth PAIR keys off it (both get Sign out, neither sees Sign
|
||||
// in); the admin-ONLY surfaces (Tune, Save as doc, ?chat= boot load)
|
||||
// still key off isAdmin alone.
|
||||
let signedIn = false;
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
|
||||
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
|
||||
@@ -1842,8 +1849,12 @@ let isAdmin = false;
|
||||
let docsRepoConfigured = false;
|
||||
|
||||
function applyAuthState() {
|
||||
if (signInLink) signInLink.hidden = isAdmin;
|
||||
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
||||
// Phase 79 (task 05): the pair keys off the authenticated role — a
|
||||
// token user (isAdmin false, signedIn true) gets Sign out like the
|
||||
// admin and no Sign in link (idempotent with header.js's own
|
||||
// toggling, which does the same from the shared whoami).
|
||||
if (signInLink) signInLink.hidden = signedIn;
|
||||
if (signOutBtn) signOutBtn.hidden = !signedIn;
|
||||
}
|
||||
|
||||
function startNewChat() {
|
||||
@@ -1855,6 +1866,14 @@ function startNewChat() {
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
if (emptyState) emptyState.hidden = false;
|
||||
// Phase 80 (task 03): the empty state is BACK — the onboarding row was
|
||||
// fetched once at boot, and while the user was chatting the last-3
|
||||
// state moved on. Refetch through the existing progressive-enhancement
|
||||
// path (fetches /api/suggestions, re-renders #suggestions in place,
|
||||
// swallows its own failures — a 401 for an anonymous visitor or a
|
||||
// network drop just leaves the row as-is, no error spam). The
|
||||
// in-flight-turn guard above means this only runs for a real new chat.
|
||||
loadSuggestions();
|
||||
clearErrorBanner();
|
||||
setUiState(UI_STATE.idle);
|
||||
input.value = "";
|
||||
@@ -2337,10 +2356,27 @@ window.addEventListener("pagehide", () => {
|
||||
the anonymous removal of the tuning surface both happen inside
|
||||
initSharedHeader() now. Phase 55 (A2): the local restore hydrates
|
||||
currentChatId from the record (restoreConversation), so the row link
|
||||
survives a plain reload — no Save pill to reveal anymore. */
|
||||
survives a plain reload — no Save pill to reveal anymore.
|
||||
Phase 79 (task 05): the token gate (mountGate) settles BEFORE the
|
||||
header boots — a cached token's silent re-auth lands before the
|
||||
first whoami fires, and the header + the chat-page gating read the
|
||||
post-auth role (the auth pair off `authenticated`, the admin-only
|
||||
surfaces off role === "admin"). */
|
||||
(async () => {
|
||||
// Phase 79 (task 05): the token gate settles FIRST — a cached
|
||||
// bor.token is re-sent to /api/token-auth (silently) BEFORE the
|
||||
// first whoami fires, so initSharedHeader below sees the
|
||||
// POST-re-auth role deterministically (no stale "Sign in" for a
|
||||
// returning token user; the gate and the header share the cached
|
||||
// whoami promise — still exactly one /api/whoami per page load).
|
||||
// onAuthed is a no-op in the shell: the lazy views mount on first
|
||||
// show exactly as today (mount-once, hide-forever untouched), and
|
||||
// the already-mounted views keep their state.
|
||||
await mountGate(document.getElementById("main"), () => {});
|
||||
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
|
||||
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
||||
const who = await fetchWhoami(); // the same cached promise — one whoami
|
||||
isAdmin = who.role === "admin"; // phase 79: admin-only surfaces key off role
|
||||
signedIn = who.authenticated; // the auth pair keys off the authenticated role
|
||||
// Phase 59: /api/config is settled BEFORE any bubble renders —
|
||||
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
|
||||
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
|
||||
|
||||
+37
-12
@@ -26,7 +26,13 @@
|
||||
* renders via renderDocument into #doc-title / #doc-meta /
|
||||
* #doc-content. A missing document (unknown pair, missing params,
|
||||
* network error) shows the designed not-found card with a link back
|
||||
* to the Sources page.
|
||||
* to the Sources page. Phase 79 (task 05): the content endpoint is
|
||||
* require_user-gated — a direct ANONYMOUS URL meets the inline
|
||||
* token gate instead (the page document loads; the gated data does
|
||||
* not): mountGate settles the auth (silent re-auth of a cached
|
||||
* token, then the shared cached whoami) and the boot sequence
|
||||
* (initSharedHeader + load) runs as its onAuthed — only a
|
||||
* signed-in role (admin or token user) ever fetches the content.
|
||||
*
|
||||
* XSS-safe by construction: markdown is escaped before transform, raw
|
||||
* formats are set via textContent, and every document-derived string
|
||||
@@ -63,6 +69,7 @@
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate
|
||||
|
||||
/* Phase 39: the page title's display name — window.BOR_BRAND (set at
|
||||
* parse time by the classic assets/brand.js, refreshed from
|
||||
@@ -357,16 +364,35 @@ if (document.querySelector("#doc-title")) {
|
||||
|
||||
/* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
|
||||
* viewer now carries the SAME standard bar as every other page —
|
||||
* nav incl. the admin-only links, Tuning toggle, Sync, New chat, the
|
||||
* auth pair — all toggled here on the module's cached whoami.
|
||||
* Independent of the doc fetch (its own IIFE — load() below never
|
||||
* waits on it).
|
||||
* (fetchIsAdmin is imported for parity with the other header
|
||||
* consumers — the module's cached promise is the single whoami per
|
||||
* page either way.) */
|
||||
(async () => {
|
||||
await initSharedHeader();
|
||||
})();
|
||||
* nav incl. the admin-only links, the auth pair — toggled on the
|
||||
* module's cached whoami (the single whoami per page; fetchIsAdmin
|
||||
* is imported for docAdminReady's parity — the same cached promise
|
||||
* either way).
|
||||
*
|
||||
* Phase 79 (task 05): the gate settles the auth FIRST — silent
|
||||
* re-auth of a cached token, then the role check (on the shared
|
||||
* cached whoami) — and the EXISTING boot sequence runs only on the
|
||||
* SETTLED role: onAuthed (the content load) for a signed-in role
|
||||
* ONLY (anonymous never fetches the content — the inline gate is
|
||||
* the surface, no not-found card for an auth failure; #main is
|
||||
* inert while the gate is visible — WCAG, the inert-pair contract),
|
||||
* and the shared header boots in the .then AFTER the gate settles
|
||||
* for EVERY role (the gate locks #main, not the header — the
|
||||
* anonymous contract is byte-identical to the shell: Sign in
|
||||
* offered, admin links ship hidden, the steering panel removed).
|
||||
* Awaiting the gate first is what makes the header race-free: a
|
||||
* silent re-auth lands before the first whoami fires, so the header
|
||||
* reads the post-auth role exactly once (no stale anonymous bar for
|
||||
* a returning token user, no second whoami). An admin (or a
|
||||
* validly cached token user) gets onAuthed immediately — the gate
|
||||
* (which ships hidden + inert) never shows. The admin-only edit
|
||||
* affordance (docAdminReady() inside renderDocument) stays
|
||||
* admin-only — it runs only in the post-auth render path. */
|
||||
mountGate(document.getElementById("main"), () => {
|
||||
load();
|
||||
}).then(() => {
|
||||
void initSharedHeader(); // every role — on the SETTLED whoami
|
||||
});
|
||||
|
||||
/* The New chat binding is module-owned (assets/header.js, phase 34
|
||||
* task 02 — the SINGLE binding): on this non-chat page it clears the
|
||||
@@ -391,5 +417,4 @@ if (document.querySelector("#doc-title")) {
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
* (the exact Sources page gate pattern, and NO /api/git-sources
|
||||
* call is made); admin → gate hidden, #git-sources-content
|
||||
* revealed, loadSources().
|
||||
*
|
||||
* • loadSources() — GET /api/git-sources → the table rows
|
||||
* (#git-sources-tbody), the env-fallback note's visibility
|
||||
* (from_env), and the empty state. Each row leads with its kind
|
||||
@@ -128,6 +129,20 @@
|
||||
* aria-live=polite): the screen-reader confirmation for loads,
|
||||
* adds, and removals.
|
||||
*
|
||||
* Phase 77 (task 02) — the re-show refresh: 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 Sources nav link, or back/forward) — the first show (mount)
|
||||
* and boot never (the mount's own load is the first fetch). This
|
||||
* module listens on root and re-runs `loadSources()`, whose re-call
|
||||
* resets all three list states: the populated render (renderSources
|
||||
* replaces the tbody + re-syncs the empty state) and the load error
|
||||
* (hideLoadError() runs on the success path, so an error followed by
|
||||
* a successful refresh clears it). The listener is armed only in the
|
||||
* ADMIN branch, after the whoami gate passes: anonymous shows the
|
||||
* gate and never fetches /api/git-sources (the Sources-page gate
|
||||
* pattern).
|
||||
*
|
||||
* Scope boundary (phase locked decisions): adding a git repo does
|
||||
* NOT clone — the sync service (server-side) does that. Removing a
|
||||
* source, however, performs the FULL cleanup server-side (phase 69):
|
||||
@@ -908,6 +923,13 @@ export async function mount(root) {
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
/* Phase 77 (task 02): a user-initiated re-show of this already-
|
||||
mounted view makes the router dispatch bor:view-refresh on the
|
||||
section — re-run loadSources then (its re-call resets the
|
||||
populated / empty / load-error states). Armed ONLY here, after
|
||||
the whoami gate passed: anonymous shows the gate and must never
|
||||
fetch /api/git-sources (the Sources-page gate pattern). */
|
||||
root.addEventListener("bor:view-refresh", () => loadSources());
|
||||
await loadSources();
|
||||
// Phase 64 (task 05): re-attach a running scan (a reload mid-scan
|
||||
// resumes the Processing state) or re-render a terminal run's
|
||||
|
||||
+106
-27
@@ -17,7 +17,12 @@
|
||||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||||
* spirit), so no anonymous user ever sees one for a frame; and
|
||||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||||
* here from app.js so there is exactly one implementation;
|
||||
* here from app.js so there is exactly one implementation.
|
||||
* Phase 79 (task 05): it ALSO drops the cached token
|
||||
* (localStorage["bor.token"], try/catch — the fail-silence storage
|
||||
* contract) BEFORE the reload, so a signing-out token user meets
|
||||
* the gate again on the next load (the server session is wiped by
|
||||
* the logout; the localStorage key must go with it);
|
||||
* • the mobile hamburger binding (phase 46, owner permission
|
||||
* 2026-08-27, TODO.md L9) — at ≤640px (CSS hides the button
|
||||
* elsewhere) the #nav-toggle button opens the nav as an animated
|
||||
@@ -68,39 +73,96 @@
|
||||
* every page; a page that lacks one simply skips it.
|
||||
*
|
||||
* whoami is fetched at most ONCE per page load: the promise is cached in
|
||||
* the module-level `adminPromise`, so app.js's tuning gate, the sources
|
||||
* the module-level `whoamiPromise`, so app.js's tuning gate, the sources
|
||||
* page's catalog gate, and the header toggling all share one request.
|
||||
* Anonymous-safe: any network failure resolves to false (the anonymous
|
||||
* UI), mirroring the per-page catch the pages used before phase 19.
|
||||
* Phase 79 (task 05): the cache stores the FULL response —
|
||||
* `{ authenticated, role }` (role: "admin" | "user" | "anonymous") —
|
||||
* not just the admin flag: the token-gate module (assets/token-gate.js)
|
||||
* reuses it for its role check, and `resetWhoami()` invalidates it
|
||||
* right after a mid-page auth (a silent re-auth or an interactive
|
||||
* login) so the next fetchWhoami() is a fresh post-auth request.
|
||||
* Anonymous-safe: any network failure or non-2xx resolves to
|
||||
* `{ authenticated: false, role: "anonymous" }` (the anonymous UI),
|
||||
* mirroring the per-page catch the pages used before phase 19.
|
||||
*
|
||||
* A10/A11 untouched: no API change, no CDN, no state beyond the cached
|
||||
* promise; the soft gate page and the A10 API split are unchanged —
|
||||
* this is UI visibility only.
|
||||
*/
|
||||
|
||||
let adminPromise = null;
|
||||
/* The anonymous fallback (phase 79): non-2xx, a network failure, or a
|
||||
malformed body all resolve to the anonymous role — the UI degrades
|
||||
to the guest surface, never to an error (the phase-19 anonymous-safe
|
||||
contract, unchanged in spirit). */
|
||||
const ANONYMOUS_WHOAMI = Object.freeze({ authenticated: false, role: "anonymous" });
|
||||
|
||||
/* The SINGLE /api/whoami call site for the whole frontend. First call
|
||||
stores the promise in `adminPromise`; every later call — on this page
|
||||
— returns the same promise, i.e. exactly one request per page load.
|
||||
Anonymous-safe: non-2xx or a network failure resolves to false. */
|
||||
export function fetchIsAdmin() {
|
||||
if (!adminPromise) {
|
||||
adminPromise = fetch("/api/whoami")
|
||||
.then(async (r) => (r.ok ? (await r.json()).authenticated === true : false))
|
||||
.catch(() => false);
|
||||
let whoamiPromise = null;
|
||||
|
||||
/* The SINGLE /api/whoami call site for the whole frontend (phase 79,
|
||||
task 05: the cache now stores the FULL response — { authenticated,
|
||||
role } — not just the admin flag). First call stores the promise in
|
||||
`whoamiPromise`; every later call — on this page — returns the same
|
||||
promise, i.e. exactly one request per page load. Anonymous-safe:
|
||||
non-2xx or a network failure resolves to the anonymous role. */
|
||||
export function fetchWhoami() {
|
||||
if (!whoamiPromise) {
|
||||
whoamiPromise = fetch("/api/whoami")
|
||||
.then(async (r) => {
|
||||
if (!r.ok) return ANONYMOUS_WHOAMI;
|
||||
const data = await r.json();
|
||||
return {
|
||||
authenticated: data.authenticated === true,
|
||||
role:
|
||||
data.role === "admin" || data.role === "user"
|
||||
? data.role
|
||||
: "anonymous",
|
||||
};
|
||||
})
|
||||
.catch(() => ANONYMOUS_WHOAMI);
|
||||
}
|
||||
return adminPromise;
|
||||
return whoamiPromise;
|
||||
}
|
||||
|
||||
/* The phase-16/19 contract every existing admin gate consumes: the
|
||||
role check is `role === "admin"` — a token user (role "user") is
|
||||
authenticated but NOT an admin, so every admin-only surface keys off
|
||||
this (never off `authenticated`). SAME single request: it delegates
|
||||
to fetchWhoami(), so all existing callers keep working with zero
|
||||
changes. */
|
||||
export function fetchIsAdmin() {
|
||||
return fetchWhoami().then((w) => w.role === "admin");
|
||||
}
|
||||
|
||||
/* Phase 79 (task 05): the token gate changes the session MID-PAGE (a
|
||||
silent re-auth of a cached token, or an interactive login) — a
|
||||
whoami cached BEFORE that auth (fired at boot) is stale. The gate
|
||||
clears the cache right after a successful auth, so the NEXT
|
||||
fetchWhoami() is a fresh request carrying the post-auth role — and
|
||||
every consumer that awaits it afterwards (the header re-boot, the
|
||||
view gates) reuses that one fresh promise. */
|
||||
export function resetWhoami() {
|
||||
whoamiPromise = null;
|
||||
}
|
||||
|
||||
/* Toggle the shared header controls, only the ones present on this page
|
||||
(querySelector, null-safe — missing → no-op). Returns the admin flag
|
||||
so callers can reuse it instead of awaiting fetchIsAdmin() again (the
|
||||
so callers can reuse it instead of awaiting fetchWhoami() again (the
|
||||
cached promise makes both awaits the same single request). */
|
||||
export async function initSharedHeader() {
|
||||
const admin = await fetchIsAdmin();
|
||||
// The Sign in link: hidden for the admin, visible otherwise — and its
|
||||
// href is rewritten to return the admin to THIS page after login
|
||||
const whoami = await fetchWhoami();
|
||||
const admin = whoami.role === "admin";
|
||||
// Phase 79 (task 05): the auth PAIR keys off the authenticated role —
|
||||
// admin OR token user: both get Sign out (the binding below drops the
|
||||
// cached token too) and neither sees Sign in. The admin-ONLY surfaces
|
||||
// (the nav links, the steering refresh) still key off role ===
|
||||
// "admin": a token user gets the anonymous branch — the links stay
|
||||
// hidden and the steering panel is REMOVED from the DOM (/api/steering
|
||||
// 403s a user, so it must never be fetched). Admin and anonymous
|
||||
// behavior is byte-identical to phase 16/19.
|
||||
const signedIn = whoami.authenticated;
|
||||
// The Sign in link: hidden for any authenticated role (admin or
|
||||
// token user — phase 79), visible for anonymous — and its href is
|
||||
// rewritten to return the admin to THIS page after login
|
||||
// (phase 34 task 02: "return to where you were"). The markup keeps its
|
||||
// own static ?next= as the no-JS fallback. location.pathname is always
|
||||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||||
@@ -118,10 +180,10 @@ export async function initSharedHeader() {
|
||||
const nextPath = window.location.pathname || "/";
|
||||
const signInNext = nextPath.startsWith("/shared/") ? "/" : nextPath;
|
||||
document.querySelectorAll(".sign-in-link").forEach(link => {
|
||||
link.hidden = admin;
|
||||
link.hidden = signedIn;
|
||||
link.href = "/login.html?next=" + signInNext;
|
||||
});
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !admin; });
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => { btn.hidden = !signedIn; });
|
||||
const navSources = document.querySelector("#nav-sources");
|
||||
if (navSources) navSources.hidden = !admin;
|
||||
// Phase 35 (owner permission 2026-08-26): the Sources nav link —
|
||||
@@ -140,12 +202,20 @@ export async function initSharedHeader() {
|
||||
// the Tuning link above.
|
||||
const navHistory = document.querySelector("#nav-history");
|
||||
if (navHistory) navHistory.hidden = !admin;
|
||||
// Phase 79 (task 06): the Tokens nav link (the shell's sixth view —
|
||||
// it ships ONLY in the shell's header) — admin-only, the same
|
||||
// ship-hidden / reveal-for-admin contract as the History link above.
|
||||
// Null-safe: a page without the link (the viewer / login / shared
|
||||
// pages) is a no-op. A token user (role "user") never sees it.
|
||||
const navTokens = document.querySelector("#nav-tokens");
|
||||
if (navTokens) navTokens.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
// the list (fire-and-forget). Anonymous: the panel is REMOVED from
|
||||
// the DOM entirely — the phase-16 contract says "absent", not just
|
||||
// hidden — and /api/steering is never fetched.
|
||||
// the list (fire-and-forget). Anonymous AND token user (phase 79):
|
||||
// the panel is REMOVED from the DOM entirely — the phase-16 contract
|
||||
// says "absent", not just hidden — and /api/steering is never fetched
|
||||
// (it 403s a user; only the admin's notes steer the prompt).
|
||||
if (admin) {
|
||||
if (steeringPanel) refreshSteering();
|
||||
} else {
|
||||
@@ -169,9 +239,12 @@ export function clearChatStorage() {
|
||||
import, so every page that loads header.js gets it exactly once.
|
||||
Binds to all .sign-out-btn elements (bar copy for desktop + mobile
|
||||
dropdown copy for ≤640px). Disable during the call, POST /api/logout
|
||||
(the result is ignored — the reload resets the UI either way), then
|
||||
reload so the header re-resolves to the anonymous state (Sign in
|
||||
back, Sources gone). */
|
||||
(the result is ignored — the reload resets the UI either way), drop
|
||||
the cached token (phase 79: one logout clears BOTH the server
|
||||
session and the localStorage key — a signing-out token user meets
|
||||
the gate again), then reload so the header re-resolves to the
|
||||
anonymous state (Sign in back, Sources gone, the gate back for the
|
||||
not-yet-token holder). */
|
||||
document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
@@ -180,6 +253,12 @@ document.querySelectorAll(".sign-out-btn").forEach(btn => {
|
||||
} catch {
|
||||
/* the reload resets the UI either way */
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem("bor.token");
|
||||
} catch {
|
||||
/* private mode / storage error — the server logout already signed
|
||||
out; the next load re-gates either way */
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,35 @@
|
||||
* at boot via ?chat=, and the router never intercepts them (they
|
||||
* are not navbar links, and their query string keeps them out of
|
||||
* the VIEW map).
|
||||
*
|
||||
* Phase 77 (task 01) — the re-show refresh: 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 History nav link, or back/forward) — the first show (mount)
|
||||
* and boot never (the mount's own load is the first fetch). This
|
||||
* module listens on root and re-runs `loadChats()`, which is now
|
||||
* re-entrant: a re-load drops the data rows (the hidden
|
||||
* #history-empty-row stays in the tbody) before fetching, so the list
|
||||
* is REPLACED — never duplicated. The listener is armed only in the
|
||||
* ADMIN branch, after the whoami gate passes: anonymous shows the
|
||||
* gate and never fetches (the phase-50 contract the story E2E pins).
|
||||
*
|
||||
* Phase 77 (task 03) — the explicit refresh control (TODO.md L3:
|
||||
* "The history page should also have a refresh button."): the
|
||||
* #history-refresh button in the view's page-head (OUTSIDE the table
|
||||
* wrap — reachable while the empty state is showing too). Bound only
|
||||
* in the admin branch; the anonymous branch HIDES it (the gate is
|
||||
* what anonymous sees — no dead control beside the sign-in gate).
|
||||
* Lifecycle: click → disable (no double-fire while in flight) →
|
||||
* `loadChats()` (re-entrant — the list is replaced) → announce the
|
||||
* outcome in #history-status → re-enable (success AND failure, the
|
||||
* finally). Success — a 0-row fetch is a success — lands
|
||||
* `Saved chats refreshed.`; the failure lines now live INSIDE
|
||||
* loadChats itself (the house copy: `Couldn't load saved chats — is
|
||||
* the app reachable?` on a network error, `Couldn't load saved chats
|
||||
* — try again.` on a non-2xx), so EVERY caller of a failed load —
|
||||
* the mount's first load, a re-show, the button — sees the outcome
|
||||
* (the §7.4 never-stale contract; a silent empty table is gone).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -89,6 +118,9 @@ export async function mount(root) {
|
||||
const emptyRow = root.querySelector("#history-empty-row");
|
||||
const gateEl = root.querySelector("#history-gate");
|
||||
const statusEl = root.querySelector("#history-status");
|
||||
// Phase 77 (task 03): the explicit refresh control — the page-head
|
||||
// button (outside the table wrap, so the empty state never hides it).
|
||||
const refreshBtn = root.querySelector("#history-refresh");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
@@ -441,28 +473,60 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row. */
|
||||
server's order). A 0-row fetch shows the empty-state row.
|
||||
Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the
|
||||
list, not append a duplicate set — the data rows (every <tr>
|
||||
EXCEPT the hidden #history-empty-row, which the load itself
|
||||
re-hides / reveals) are dropped before the fetch.
|
||||
Phase 77 (task 03): a FAILED load announces its line in the live
|
||||
region — the house copy (network: "is the app reachable?"; non-2xx:
|
||||
"try again.") — and the load 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, so the
|
||||
refresh button's handler can land its own success line. */
|
||||
async function loadChats() {
|
||||
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/chats");
|
||||
} catch {
|
||||
announce("Couldn't load saved chats — is the app reachable?");
|
||||
showEmptyState();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!r.ok) {
|
||||
announce("Couldn't load saved chats — try again.");
|
||||
showEmptyState();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Phase 77 (task 03): the refresh button's in-flight run — the
|
||||
re-entrant load (the failure line lands inside it) + the success
|
||||
line + the re-enable (success AND failure — the finally, so a
|
||||
click can never leave the button stuck disabled). */
|
||||
async function refreshChats() {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await loadChats();
|
||||
} finally {
|
||||
if (refreshBtn) refreshBtn.disabled = false;
|
||||
}
|
||||
if (ok) announce("Saved chats refreshed.");
|
||||
}
|
||||
|
||||
/* ---------- view boot (phase 76 task 03) ----------
|
||||
@@ -476,8 +540,34 @@ export async function mount(root) {
|
||||
if (!(await fetchIsAdmin())) {
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
if (refreshBtn) refreshBtn.hidden = true; // no dead control beside the gate
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (refreshBtn) refreshBtn.hidden = false; // admin: the control is live
|
||||
/* 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 (loadChats is re-entrant, so the list is replaced).
|
||||
The listener is armed ONLY here, after the whoami gate passed:
|
||||
anonymous shows the gate and must never fetch (phase 50). `started`
|
||||
flips true once the first loadChats() is made (the next line), so
|
||||
the listener can only ever re-run a load the mount already did. */
|
||||
let started = false;
|
||||
root.addEventListener("bor:view-refresh", () => {
|
||||
if (started) loadChats();
|
||||
});
|
||||
/* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) —
|
||||
the button's own lifecycle: click → disable (no double-fire while
|
||||
the request is in flight) → refreshChats (the re-entrant load +
|
||||
the outcome line + the re-enable). It is bound HERE, in the admin
|
||||
branch only: the view is admin-gated, and anonymous never sees
|
||||
the button (it is hidden above). */
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
refreshBtn.disabled = true; // no double-fire while in flight
|
||||
void refreshChats();
|
||||
});
|
||||
}
|
||||
started = true;
|
||||
loadChats();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
* upload-progress poller) persist across every switch; that
|
||||
* persistence IS the phase-76 fix. The chat view needs no module:
|
||||
* app.js already ran at shell boot.
|
||||
* • refresh hook (phase 77): a user-initiated re-show of an
|
||||
* already-mounted view dispatches the `bor:view-refresh` CustomEvent
|
||||
* on the view's <section> root — exactly when the view is shown
|
||||
* AGAIN: a switch back onto it, a re-click of its own (active) nav
|
||||
* link (NO pushState — the URL is already its path), or back/
|
||||
* forward (popstate) onto it. The FIRST show (the mount) and boot
|
||||
* NEVER fire it — the mount's own load is the first fetch. A view
|
||||
* module opts in by listening on its own root inside mount();
|
||||
* views that do not listen (Chat — app.js) are unaffected: the
|
||||
* in-flight stream and the local conversation survive (the phase-76
|
||||
* LOCKED refinement).
|
||||
* • show = drop hidden + inert, hide = add BOTH (WCAG: a hidden view
|
||||
* must not receive focus or keyboard traversal — the inert pair
|
||||
* pins the [hidden] contract in the a11y tree, AGENTS.md rule 5).
|
||||
@@ -50,9 +61,9 @@
|
||||
* chat view, runs at shell boot exactly as before) → router.js
|
||||
* (module — this file). No CDN, no framework, no bundler dependency:
|
||||
* a plain ES module whose dynamic imports (./tuning.js, task 01;
|
||||
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03)
|
||||
* resolve relatively in dev and are inlined by the Containerfile's
|
||||
* esbuild stage in the image.
|
||||
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03;
|
||||
* ./tokens.js in phase 79 task 06) resolve relatively in dev and are
|
||||
* inlined by the Containerfile's esbuild stage in the image.
|
||||
*/
|
||||
|
||||
/* ---------- the view map (pathname → view name) ----------
|
||||
@@ -67,6 +78,7 @@ const VIEW = {
|
||||
"/sources.html": "rag", // phase 76 task 02: the RAG view (knowledge base)
|
||||
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
||||
};
|
||||
|
||||
/* The nav-link href the router stamps active for each view (the
|
||||
@@ -77,6 +89,7 @@ const VIEW_PATH = {
|
||||
rag: "/sources.html",
|
||||
"git-sources": "/git-sources.html",
|
||||
history: "/history.html",
|
||||
tokens: "/tokens.html",
|
||||
};
|
||||
|
||||
/* The lazy view modules — ONLY the non-chat views (chat needs no
|
||||
@@ -89,6 +102,7 @@ const VIEW_MODULES = {
|
||||
rag: () => import("./sources.js"), // phase 76 task 02
|
||||
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
|
||||
history: () => import("./history.js"), // phase 76 task 03
|
||||
tokens: () => import("./tokens.js"), // phase 79 task 06
|
||||
};
|
||||
|
||||
/* Per-view document.head values, carried over from the old pages'
|
||||
@@ -101,6 +115,7 @@ const TITLES = {
|
||||
rag: "Sources · Brain of Reese", // old sources.html <title>
|
||||
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
|
||||
history: "Saved chats · Brain of Reese", // old history.html <title>
|
||||
tokens: "Access tokens · Brain of Reese",
|
||||
};
|
||||
const DESCRIPTIONS = {
|
||||
chat:
|
||||
@@ -112,6 +127,7 @@ const DESCRIPTIONS = {
|
||||
"Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).",
|
||||
history:
|
||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
||||
tokens: "Generate and revoke the API tokens that let people use the app.",
|
||||
};
|
||||
|
||||
/* The brand-resolved display name (phase 39 — brand.js is the single
|
||||
@@ -136,9 +152,10 @@ for (const name of new Set(Object.values(VIEW))) {
|
||||
}
|
||||
|
||||
/* The mount-once guard: a view is imported + mounted at most ONCE per
|
||||
document life — re-shows are show/hide only (no refetch, no
|
||||
re-mount; the view's state persists). Chat starts mounted: app.js
|
||||
owns it and ran at shell boot. */
|
||||
document life — re-shows are show/hide only (no re-mount; the view's
|
||||
state persists). A listening view re-fetches on a re-show through the
|
||||
phase-77 refresh hook (bor:view-refresh), not a re-mount. Chat starts
|
||||
mounted: app.js owns it and ran at shell boot. */
|
||||
const mounted = { chat: true };
|
||||
|
||||
const nav = document.getElementById("app-nav");
|
||||
@@ -156,6 +173,12 @@ async function switchTo(name, { userInitiated }) {
|
||||
const root = viewEls[name];
|
||||
if (!root) return;
|
||||
|
||||
/* Phase 77: capture the mount state BEFORE the mount block — a
|
||||
re-show of an already-mounted view dispatches bor:view-refresh
|
||||
(further down); the first show (the mount) and boot never do (the
|
||||
mount's own load is the first fetch). */
|
||||
const wasMounted = mounted[name];
|
||||
|
||||
/* Mount-once: the lazy module is imported on FIRST show only, then
|
||||
mounted into the view's section. The guard runs BEFORE the import
|
||||
(a re-show never re-imports) and is set only after mount resolves
|
||||
@@ -193,6 +216,16 @@ async function switchTo(name, { userInitiated }) {
|
||||
|
||||
current = name;
|
||||
|
||||
/* Phase 77: the re-show refresh — the view is already visible and
|
||||
the head/nav state is written (event order: visible → refresh),
|
||||
the focus/scroll tail runs after. Gated on the pre-mount capture:
|
||||
a first show (the mount's own load is the first fetch) and boot
|
||||
never dispatch. A listening view re-runs its load; the chat view
|
||||
never listens (phase-76 stream survival). */
|
||||
if (wasMounted) {
|
||||
root.dispatchEvent(new CustomEvent("bor:view-refresh"));
|
||||
}
|
||||
|
||||
/* Focus the target view ONLY on user-initiated switches (navbar
|
||||
click / popstate) — never on initial boot (no focus steal on
|
||||
load). The top landing mirrors what the old per-view page loads
|
||||
@@ -218,7 +251,15 @@ if (nav) {
|
||||
if (!(href in VIEW)) return; // not a same-shell view — real navigation
|
||||
e.preventDefault();
|
||||
const name = VIEW[href];
|
||||
if (name === current) return; // already visible (the menu still closes)
|
||||
/* Phase 77: a re-click of the ACTIVE view's own link is a
|
||||
re-fetch, not a no-op — the same refresh event the re-show
|
||||
dispatches. No pushState (the URL is already this view's path);
|
||||
the mobile menu still closes (the container handler runs
|
||||
regardless). */
|
||||
if (name === current) {
|
||||
viewEls[name].dispatchEvent(new CustomEvent("bor:view-refresh"));
|
||||
return;
|
||||
}
|
||||
history.pushState({ view: name }, "", href);
|
||||
switchTo(name, { userInitiated: true });
|
||||
});
|
||||
|
||||
@@ -33,6 +33,20 @@
|
||||
* The sync button (phase 32) + the live two-job progress contract
|
||||
* (phase 64 task 04) and the table rows (phase 26: same-page modal
|
||||
* links) are unchanged in content — only the boot shape moved.
|
||||
*
|
||||
* Phase 77 (task 02) — the re-show refresh: 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 RAG nav link, or back/forward) — the first show (mount) and
|
||||
* boot never (the mount's own load is the first fetch). This module
|
||||
* listens on root and re-runs `loadDocs()`, which is now re-entrant:
|
||||
* a re-load drops the tbody's rows BEFORE the fetch, so a refresh
|
||||
* from a populated list into an empty result replaces the list
|
||||
* (no ghost rows) — the History pattern (task 01). #sources-empty
|
||||
* lives OUTSIDE the tbody (a .empty-state div), so the top clear is
|
||||
* a bare replaceChildren(). The listener is armed only in the ADMIN
|
||||
* branch, after the whoami gate passes: anonymous shows the gate and
|
||||
* never fetches /api/docs (the phase-16 soft rule).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -469,25 +483,40 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
|
||||
/* Phase 77 (task 02): re-entrant — the top clear (the History
|
||||
pattern from task 01) drops the tbody's rows BEFORE the fetch,
|
||||
so a re-show refresh from a populated list into an empty result
|
||||
replaces the list instead of leaving ghost rows. #sources-empty
|
||||
lives OUTSIDE the tbody (a .empty-state div, not a row), so the
|
||||
clear is a bare replaceChildren(). Phase 79 (task 04): the clear
|
||||
alone is NOT enough when two loads interleave — the boot re-attach
|
||||
(applySyncSuccess → loadDocs) and the boot-time loadDocs both clear
|
||||
first, then the SLOWER fetch appends after the newer load's clear,
|
||||
duplicating every row (2×). The monotonic seq token invalidates an
|
||||
in-flight load the moment a newer one starts: only the newest load
|
||||
may touch the DOM after its await. */
|
||||
let loadSeq = 0;
|
||||
async function loadDocs() {
|
||||
const my = ++loadSeq;
|
||||
tbody.replaceChildren();
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/docs");
|
||||
} catch {
|
||||
showEmpty();
|
||||
if (my === loadSeq) showEmpty();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showEmpty();
|
||||
if (my === loadSeq) showEmpty();
|
||||
return;
|
||||
}
|
||||
const { documents } = await r.json();
|
||||
if (my !== loadSeq) return; // a newer load owns the tbody now
|
||||
if (!documents.length) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.replaceChildren();
|
||||
let totalChunks = 0;
|
||||
let last = "";
|
||||
for (const d of documents) {
|
||||
@@ -564,5 +593,12 @@ export async function mount(root) {
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
/* Phase 77 (task 02): a user-initiated re-show of this already-
|
||||
mounted view makes the router dispatch bor:view-refresh on the
|
||||
section — re-load the catalog then (loadDocs is re-entrant).
|
||||
Armed ONLY here, after the whoami gate passed: anonymous shows
|
||||
the gate and must never fetch /api/docs (the phase-16 soft
|
||||
rule the story E2E pins). */
|
||||
root.addEventListener("bor:view-refresh", () => loadDocs());
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
+372
-73
@@ -67,30 +67,12 @@ body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ---------- Animated background (pure CSS, zero JS — phase 08; reworked
|
||||
phase 25: no movement, only fading light) ----------
|
||||
Owner direction (2026-08-25, verbatim): "It should be smooth,
|
||||
fluxuating, dimming and brightening, but not moving. Different bright
|
||||
spots should slowly fade in and out."
|
||||
- NO movement anywhere in the background: no grid drift, no
|
||||
transform/scale, no background-position animation. The 44px/60s grid
|
||||
drift (0.73px/s, diagonally down-right) rasterizes sub-pixel by
|
||||
sub-pixel and reads as a once-per-second jitter; the 14s whole-layer
|
||||
opacity+scale pulse reads as a uniform blink. Both are gone.
|
||||
- Three independent soft glow spots, each fading in and out on its own
|
||||
SLOW opacity-only cycle — 26s / 34s / 42s, ease-in-out, with negative
|
||||
delays (-12s, -23s) so the cycles run out of phase (LCM 4641s: the
|
||||
composite pattern effectively never repeats within a viewing
|
||||
session). The total light fluxuates smoothly and irregularly.
|
||||
- html::before / html::after join body::before / body::after as
|
||||
background layers: <html> is the root stacking context, so their
|
||||
z-index:-1 pseudo-elements paint ABOVE the var(--bg) canvas and
|
||||
BELOW the transparent, non-stacking <body>'s content — the
|
||||
no-occlusion contract (html owns the canvas, body stays
|
||||
transparent) is unchanged.
|
||||
- No filter (phase-08 no-blur perf anchor), no JS, no new assets;
|
||||
opacity-only keyframes stay compositor-friendly.
|
||||
- prefers-reduced-motion: reduce stills all four layers. */
|
||||
/* ---------- Static background (pure CSS, zero JS) ----------
|
||||
Phase 78 (owner direction, TODO.md L4): the animated background was
|
||||
removed as too resource-intensive — the three opacity-fading glow
|
||||
spots (and their keyframes) are deleted. What remains is fully
|
||||
static: the 44px grid texture below — zero animation cost, zero JS,
|
||||
no filter/blur. */
|
||||
|
||||
/* Static grid texture: 44px cells, 1px lines at 60% --line alpha, masked
|
||||
with a widened radial fade (visible across most of the viewport,
|
||||
@@ -112,48 +94,6 @@ body::before {
|
||||
mask-image: radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%);
|
||||
}
|
||||
|
||||
/* Glow spot A — the phase-08 indigo (top-left): one soft radial spot
|
||||
fading in and out on its own 26s opacity-only cycle. */
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 56rem at 12% 8%, rgb(244 63 94 / 0.10), transparent 62%);
|
||||
animation: bg-glow-a 26s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Glow spot B — the phase-08 cyan (bottom-right): 34s cycle, -12s delay
|
||||
(out of phase with spot A). */
|
||||
html::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 60rem at 88% 92%, rgb(251 146 60 / 0.08), transparent 62%);
|
||||
animation: bg-glow-b 34s ease-in-out -12s infinite;
|
||||
}
|
||||
|
||||
/* Glow spot C — a third indigo (bottom-left): 42s cycle, -23s delay
|
||||
(out of phase with spots A and B). */
|
||||
html::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle 52rem at 14% 86%, rgb(239 68 68 / 0.08), transparent 62%);
|
||||
animation: bg-glow-c 42s ease-in-out -23s infinite;
|
||||
}
|
||||
|
||||
/* Opacity-only fades — nothing but opacity may appear in any bg-*
|
||||
keyframe (the no-movement contract, phase 25). */
|
||||
@keyframes bg-glow-a { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } }
|
||||
@keyframes bg-glow-b { 0%, 100% { opacity: 0.20; } 50% { opacity: 1; } }
|
||||
@keyframes bg-glow-c { 0%, 100% { opacity: 0.15; } 50% { opacity: 1; } }
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 72rem;
|
||||
@@ -1347,13 +1287,6 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner { animation-duration: 2s; }
|
||||
}
|
||||
/* The background layers are the only other motion on the page: under
|
||||
reduced motion they go static (grid + glows remain, just still) — all
|
||||
four layers (phase 25: the html::before / html::after spots join). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body::before, body::after, html::before, html::after { animation: none; }
|
||||
}
|
||||
|
||||
/* ---------- Banners ---------- */
|
||||
.kb-banner {
|
||||
display: flex;
|
||||
@@ -1695,6 +1628,114 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.sources-gate-link:hover { background: #7d88f5; }
|
||||
|
||||
/* Phase 79 (task 05): the in-app token gate — the gate surface of the
|
||||
two token-only pages (the shell + the document viewer, one shared
|
||||
.auth-gate markup each). A body-level FIXED overlay (the
|
||||
body-level doc-modal precedent): while visible it is the ONLY
|
||||
interactive surface — assets/token-gate.js locks the root (#main,
|
||||
inert) so focus and keyboard traversal stay inside the gate
|
||||
(WCAG, the inert-pair contract).
|
||||
Stacking: z-index 500 — above the app content (the sticky header is
|
||||
20, the skip-link 100) but BELOW the doc-modal (1000), which can
|
||||
only be opened from the unlocked app anyway. Solid --bg: the canvas
|
||||
+ grid texture live on <html> (the body stays transparent), so the
|
||||
overlay reads as the app's own surface — no blur (the phase-08
|
||||
perf anchor).
|
||||
The centered card reuses the #sources-gate visual language
|
||||
(surface card + hairline + glyph + heading + sub + action) in a
|
||||
tighter column. */
|
||||
.auth-gate {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
display: flex; /* the card is the only in-flow child — margin: auto centers it */
|
||||
overflow-y: auto; /* short viewports: the card scrolls fully into view */
|
||||
background: var(--bg);
|
||||
}
|
||||
/* Explicit (the global [hidden] rule already wins — this is the
|
||||
documented, testable contract for the skeleton). */
|
||||
.auth-gate[hidden] { display: none; }
|
||||
|
||||
.auth-gate-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.4rem;
|
||||
/* margin:auto centers AND stays scrollable on overflow (the flex
|
||||
centering pitfall: align/justify center would clip the top of a
|
||||
taller-than-viewport card). */
|
||||
margin: auto;
|
||||
width: min(28rem, calc(100vw - 2rem));
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2.5rem 1.75rem;
|
||||
}
|
||||
.auth-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
|
||||
.auth-gate-glyph svg { width: 44px; height: 44px; display: block; }
|
||||
.auth-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
|
||||
.auth-gate-sub { margin: 0; max-width: 24rem; color: var(--ink-soft); }
|
||||
|
||||
/* The token form: mono input (the token is code — the house mono
|
||||
language), a visible focus ring (WCAG — the 3px :focus-visible
|
||||
outline at offset 0 + the brand border), and the house submit
|
||||
button (the .tune-save language: dark ink on brand, 5.2:1). Both
|
||||
targets are ≥44px (the touch-target contract). */
|
||||
.auth-gate form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
.auth-gate input {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink); /* 16.7:1 on --bg */
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.65rem 0.8rem;
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
.auth-gate input::placeholder { color: var(--ink-soft); }
|
||||
.auth-gate input:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||
.auth-gate-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-gate-submit:hover:not(:disabled) { background: #7d88f5; }
|
||||
.auth-gate-submit:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* The one-line error (role=alert) — the rose/danger family the
|
||||
house alerts use (--err-ink on --err-bg, 9.3:1). */
|
||||
.auth-gate-error {
|
||||
margin: 0.75rem 0 0;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
}
|
||||
|
||||
/* The secondary "Sign in as admin" link reuses the .sources-gate-link
|
||||
pill language (its own margin-top: 0.75rem applies) — the guest's
|
||||
other door: the admin password login (the static ?next=/ is the
|
||||
no-JS fallback). */
|
||||
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
@@ -2179,6 +2220,49 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ---------- History refresh button (phase 77 task 03, TODO.md L3) ----------
|
||||
The #view-history page-head is a flex row: the title block left, the
|
||||
actions slot right (the row wraps below 640px). Scoped to
|
||||
#view-history — the other four views' page-heads are untouched.
|
||||
The button reuses the .new-chat-btn visual language: solid brand
|
||||
pill, --bg text on --brand (5.2:1, WCAG AA >=4.5:1), borderless,
|
||||
>=44px target, hover lightens the brand fill, focus-visible via the
|
||||
global 3px rule. The phase-46 auth-link convention: the label is
|
||||
visible >=640px, the glyph is the whole control below (the button's
|
||||
aria-label keeps the accessible name in both). */
|
||||
#view-history .page-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
#view-history .page-head .page-head-title { min-width: 0; }
|
||||
.history-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 0;
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.history-refresh:hover { background: #f55a72; color: var(--bg); }
|
||||
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* The refresh glyph is hidden on desktop (the label carries the
|
||||
pill); it is the whole control below 640px (the <=640 block
|
||||
mirrors the phase-46 auth-link icon-only convention). */
|
||||
.history-refresh svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* ---------- History page (phase 50) ----------
|
||||
/history.html: the admin-only saved-chats list (task 04). The
|
||||
FULL-WIDTH table in the 72rem frame (AGENTS.md rule 5 — no skinny
|
||||
@@ -2384,6 +2468,203 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Tokens view (phase 79 task 06) ----------
|
||||
The admin-issued access tokens (generate · list · revoke). The
|
||||
shell's standard frame (the .container): the page-head (h1 + sub),
|
||||
the #tokens-gate (the #history-gate / .sources-gate language), the
|
||||
create row (label + Generate), the shown-once block (the mono
|
||||
field + Copy), and the FULL-WIDTH table (AGENTS.md rule 5 — the
|
||||
.history-table language: --line hairlines, the brand-soft-tinted
|
||||
thead, row hover, the scrollable .table-wrap). Every pair reuses
|
||||
the Phase-08 AA palette: brand-ink on brand-soft 6.9:1, ink-soft
|
||||
>=5.1:1, err 9.3:1. :focus-visible via the global 3px outline
|
||||
rule. No CDN, system fonts. */
|
||||
.tokens-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* Action feedback line (role=status): the .history-status shape —
|
||||
the min-height holds the layout so a line never reflows the table. */
|
||||
.tokens-status {
|
||||
display: block;
|
||||
min-height: 1.2em;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
/* The create row: label input + Generate — flex, wraps below 640px
|
||||
(the button drops under the full-width input). */
|
||||
.token-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
#token-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
#token-label:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Generate: the .history-refresh brand pill language — the solid
|
||||
brand fill (--bg text on --brand 5.2:1, AA), the ≥44px target, the
|
||||
lightened hover fill, the dimmed :disabled (the in-flight state),
|
||||
the global :focus-visible ring. */
|
||||
.token-generate {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.1rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.token-generate:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
|
||||
.token-generate:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* The shown-once block (owner-locked A4): a quiet brand-soft card
|
||||
around the "shown once" line + the mono read-only field + Copy —
|
||||
the token reads as the app's own credential surface (brand-ink on
|
||||
brand-soft 12.4:1; ink on the field 13.8:1). */
|
||||
.token-once {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
.token-once-copy {
|
||||
margin: 0;
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.token-once-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
/* The mono read-only field: the token is DATA — mono, surface fill,
|
||||
--line border; it truncates with an ellipsis at narrow widths (the
|
||||
full value is the text selection — the inline copy fallback). */
|
||||
#token-once-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#token-once-value:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
|
||||
/* Copy: the Tune/Retry-family ghost button (the .history-share-copy
|
||||
language — --line border, transparent fill, ink-soft, ≥44px, the
|
||||
brand hover pair). */
|
||||
#token-once-copy {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
#token-once-copy:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
/* The table is FULL-WIDTH (AGENTS.md rule 5): width 100% inside the
|
||||
standard .container; the .table-wrap card + its horizontal scroll
|
||||
cover narrow widths (the phase-07 responsive contract). The
|
||||
.history-table language, verbatim. */
|
||||
.tokens-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 640px;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
.tokens-table th, .tokens-table td {
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tokens-table th {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-ink);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tokens-table tbody tr:hover { background: var(--bg); }
|
||||
.tokens-table tbody tr:last-child td { border-bottom: 0; }
|
||||
/* Label: the hand-out name (the column ellipsizes, the full label
|
||||
sits in the title attribute — tokens.js). */
|
||||
.tokens-label-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Created / Last used: locale date+time (ink-soft), the full ISO in
|
||||
the title attribute; "never" for the not-yet-used stamp. */
|
||||
.tokens-date-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
/* Status: the Active em-dash rides the cell's ink-soft (5.1:1 on
|
||||
--surface); the Revoked marker reuses the .stale-pill rose family
|
||||
(err-ink on err-bg ≈9.3:1, err-line border — the stale-pill visual
|
||||
language). */
|
||||
.tokens-status-cell { color: var(--ink-soft); white-space: nowrap; }
|
||||
/* Actions: the Revoke ghost button (the .history-delete language —
|
||||
--line border, transparent fill, ink-soft, ≥44px target) + the
|
||||
inline two-step confirm (the .history-confirm-* pair CSS above —
|
||||
the phase-50 pattern). */
|
||||
.tokens-actions { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.token-revoke {
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.token-revoke:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.token-revoke:disabled { opacity: 0.5; cursor: wait; }
|
||||
/* Empty-state row: the muted centered message at full table width
|
||||
(the .history-empty-row language, inline in the table). */
|
||||
.tokens-empty-row td {
|
||||
padding: 2.25rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--ink-soft);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Shared page (phase 51, task 03) ----------
|
||||
/shared/<token>: the anonymous read-only conversation (owner-locked
|
||||
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
|
||||
@@ -3362,6 +3643,17 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
the two-step confirm pair fits the phone width. */
|
||||
.history-actions-cell { white-space: normal; }
|
||||
.history-actions { flex-wrap: wrap; }
|
||||
/* Phase 79 task 06: the Tokens create row stacks — the label input
|
||||
takes the full width, the Generate pill drops under it (full
|
||||
width); the once field's row stacks the same way; the actions
|
||||
cell wraps so the two-step confirm pair fits the phone width
|
||||
(the table's own horizontal scroll covers the columns). */
|
||||
.token-create { flex-direction: column; align-items: stretch; }
|
||||
.token-generate { width: 100%; }
|
||||
.token-once-row { flex-direction: column; align-items: stretch; }
|
||||
#token-once-copy { width: 100%; }
|
||||
.tokens-actions-cell { white-space: normal; }
|
||||
.tokens-actions { flex-wrap: wrap; }
|
||||
/* Phase 51: the shared page squeezes like the chat column — the
|
||||
title and the note step down (the empty-state-title family); the
|
||||
shell keeps its base 46rem column (the >=1500px 92rem override
|
||||
@@ -3376,6 +3668,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
state on a touch screen. */
|
||||
.sync-btn { padding: 0.4rem 0.3rem; }
|
||||
.sync-label { display: none; }
|
||||
/* Phase 77 task 03: the History refresh pill goes icon-only like
|
||||
the phase-46 auth pill (the aria-label keeps the accessible
|
||||
name); the page-head row above wraps the pill below the title
|
||||
block when the width runs out. */
|
||||
.history-refresh { padding: 0.4rem 0.3rem; }
|
||||
.history-refresh-label { display: none; }
|
||||
.history-refresh svg { display: block; }
|
||||
.sync-result {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/* Brain of Reese — the in-app token gate (phase 79, task 05).
|
||||
*
|
||||
* The owner's sentence (TODO.md L5): "The web ui should ask for a
|
||||
* token before letting a user through and should cache that token in
|
||||
* browser storage so they don't have to keep entering it."
|
||||
*
|
||||
* One reusable mount point for the two gated surfaces:
|
||||
*
|
||||
* • the shell (index.html) — app.js's boot IIFE awaits
|
||||
* mountGate(#main, onAuthed) BEFORE its initSharedHeader;
|
||||
* • the document viewer (document.html) — document.js wires
|
||||
* mountGate(#main, onAuthed) around the existing boot sequence
|
||||
* (whoami → load content).
|
||||
*
|
||||
* Both carry the same gate markup (class .auth-gate; the ids differ —
|
||||
* #auth-gate / #doc-auth-gate — so the gate is found by CLASS: the
|
||||
* ONE .auth-gate section on the page).
|
||||
*
|
||||
* The contract (pinned at source level in
|
||||
* tests/unit/test_token_gate.py):
|
||||
*
|
||||
* • the gate SHIPS hidden + inert (the phase-16 ship-hidden pattern):
|
||||
* an authenticated boot never shows it for a frame.
|
||||
* • (1) at mount, a CACHED token (localStorage["bor.token"]) is
|
||||
* re-sent to POST /api/token-auth SILENTLY — BEFORE the whoami
|
||||
* check. On ANY failure the key is removed (the token may have
|
||||
* been revoked) and the mount falls through to the role check.
|
||||
* • (2) the role check is header.js's fetchWhoami() — the SAME
|
||||
* cached promise the page's own header boot uses (exactly one
|
||||
* /api/whoami per page load in dev): role "user" or "admin" →
|
||||
* onAuthed() (the gate NEVER shows); "anonymous" → the gate is
|
||||
* revealed (hidden AND inert dropped), the lock root is locked
|
||||
* (lockRoot.inert = true — WCAG: the locked app must not receive
|
||||
* focus or keyboard traversal, the inert-pair contract) and the
|
||||
* token input is focused.
|
||||
* • form submit → POST /api/token-auth: 204 → the token is cached
|
||||
* (localStorage, try/catch — private mode: caching is a no-op,
|
||||
* the gate still works), the whoami cache is invalidated and
|
||||
* re-fetched (the choice below), role "user" → the gate is hidden
|
||||
* again (hidden + inert re-added), the lock root unlocks,
|
||||
* onAuthed() runs, and the shared header re-boots
|
||||
* (initSharedHeader — a mid-page login must not leave the
|
||||
* anonymous header state: Sign in hidden, Sign out visible).
|
||||
* 401 (or any failure) → the #auth-gate-error (role=alert) line
|
||||
* is revealed, the input cleared and re-focused — the entered
|
||||
* token never lingers in the field.
|
||||
* • the whoami-cache choice: CLEAR THE MODULE CACHE (header.js's
|
||||
* resetWhoami) rather than a direct re-fetch — a direct fetch
|
||||
* would verify the role for the gate but leave header.js's cache
|
||||
* stale (the boot fired it pre-auth, when the page was still
|
||||
* anonymous), so the header re-boot and every later fetchWhoami()
|
||||
* would still read anonymous. Resetting makes the NEXT
|
||||
* fetchWhoami() the single fresh post-auth request for the whole
|
||||
* page (gate + header + view gates all reuse it).
|
||||
* • ALL localStorage access is try/catch (the fail-silence storage
|
||||
* contract — private mode or a storage error degrades to
|
||||
* "re-enter the token each visit", never to a broken gate).
|
||||
*
|
||||
* The module has NO import-time side effects: pages call mountGate at
|
||||
* boot (the shell from app.js's boot IIFE, the viewer from
|
||||
* document.js). On the shell the call is AWAITED before app.js's
|
||||
* initSharedHeader, so a silent re-auth lands before the FIRST whoami
|
||||
* fires — the header sees the post-auth role deterministically (no
|
||||
* stale "Sign in" for a returning token user). No CDN, no framework.
|
||||
*/
|
||||
|
||||
import { fetchWhoami, initSharedHeader, resetWhoami } from "./header.js";
|
||||
|
||||
/* The cached-token key (the owner's sentence: "cache that token in
|
||||
browser storage"). The server stores only the SHA-256 digest of the
|
||||
full token (app/core/tokens.py) — the plaintext lives here, in the
|
||||
browser, and is re-sent verbatim to /api/token-auth (the server
|
||||
hashes it for the unique-index lookup). */
|
||||
const TOKEN_KEY = "bor.token";
|
||||
|
||||
/* ---------- the fail-silence storage contract ----------
|
||||
* Every localStorage access is try/catch: private mode or a storage
|
||||
* error degrades to "re-enter the token each visit" — the gate itself
|
||||
* still works either way. */
|
||||
|
||||
const readCachedToken = () => {
|
||||
try {
|
||||
const t = localStorage.getItem(TOKEN_KEY);
|
||||
return typeof t === "string" && t.trim() !== "" ? t : null;
|
||||
} catch {
|
||||
return null; // private mode / storage error — nothing cached
|
||||
}
|
||||
};
|
||||
|
||||
const storeToken = (token) => {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* private mode: the gate still works, caching is a no-op */
|
||||
}
|
||||
};
|
||||
|
||||
const removeToken = () => {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* nothing was stored */
|
||||
}
|
||||
};
|
||||
|
||||
/* POST /api/token-auth — every failure shape (malformed / unknown /
|
||||
revoked / empty) is ONE generic 401 "invalid token" server-side
|
||||
(the phase-16 no-enumeration contract), so the client only needs
|
||||
the status: 204 = the session cookie is (re)set. */
|
||||
async function tryTokenAuth(token) {
|
||||
const r = await fetch("/api/token-auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
return r.status === 204;
|
||||
}
|
||||
|
||||
/* The in-app token gate. `lockRoot` is the element the gate locks
|
||||
while visible (#main on both pages — the locked app must not
|
||||
receive focus or keyboard traversal: the inert-pair contract);
|
||||
`onAuthed` runs ONLY once the visitor is signed in (role "user" or
|
||||
"admin") — the shell passes a no-op (the lazy views mount on first
|
||||
show exactly as today), the viewer passes its existing boot
|
||||
sequence (the shared header + the content load). */
|
||||
export async function mountGate(lockRoot, onAuthed) {
|
||||
const gate = document.querySelector(".auth-gate");
|
||||
const form = gate ? gate.querySelector("form") : null;
|
||||
const input = gate ? gate.querySelector('input[name="token"]') : null;
|
||||
const error = gate ? gate.querySelector(".auth-gate-error") : null;
|
||||
const submit = form ? form.querySelector('button[type="submit"]') : null;
|
||||
|
||||
const hideGate = () => {
|
||||
if (gate) {
|
||||
gate.hidden = true;
|
||||
gate.inert = true;
|
||||
}
|
||||
};
|
||||
|
||||
const showError = () => {
|
||||
if (error) error.hidden = false;
|
||||
if (input) {
|
||||
input.value = ""; // the entered token never lingers in the field
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
|
||||
/* (1) SILENT RE-AUTH — before the whoami check. A cached token
|
||||
is re-sent to /api/token-auth first: on success the session
|
||||
cookie is (re)set NOW, before any whoami on this page settles —
|
||||
the cache is invalidated (resetWhoami) so the role check below
|
||||
is a FRESH request (a boot-fired pre-auth whoami would still
|
||||
say anonymous). On ANY failure the key is DROPPED — the token
|
||||
may have been revoked — and the mount falls through to the role
|
||||
check (the page may already be signed in as the admin, and the
|
||||
anonymous visitor simply gets the gate). */
|
||||
const cached = readCachedToken();
|
||||
if (cached) {
|
||||
const ok = await tryTokenAuth(cached).catch(() => false);
|
||||
if (ok) {
|
||||
resetWhoami(); // the cached whoami (boot-fired, pre-auth) is stale
|
||||
const who = await fetchWhoami();
|
||||
if (who.authenticated) {
|
||||
// role "user" (the re-auth landed) or "admin" (the browser
|
||||
// also holds an admin session — admin wins, whoami's
|
||||
// contract): unlock and run onAuthed — the gate NEVER shows
|
||||
// for a cached valid token (no flash).
|
||||
if (lockRoot) lockRoot.inert = false;
|
||||
hideGate(); // defensive: it ships hidden — the boot never
|
||||
// reveals it for an authenticated role, so this is a no-op.
|
||||
onAuthed();
|
||||
return;
|
||||
}
|
||||
// token-auth said 204 but whoami still says no (the cookie did
|
||||
// not land): treat it as a dead cached token.
|
||||
removeToken();
|
||||
} else {
|
||||
removeToken(); // a failed silent re-auth drops the key
|
||||
}
|
||||
}
|
||||
|
||||
/* (2) ROLE CHECK — header.js's fetchWhoami(): the SAME cached
|
||||
promise the page's own header boot awaits (app.js on the shell,
|
||||
the viewer's onAuthed on document.html) — exactly one
|
||||
/api/whoami per page load. */
|
||||
const who = await fetchWhoami();
|
||||
if (who.authenticated) {
|
||||
// role "user" or "admin" — the gate NEVER shows.
|
||||
if (lockRoot) lockRoot.inert = false;
|
||||
onAuthed();
|
||||
return;
|
||||
}
|
||||
|
||||
/* Anonymous: reveal the gate (drop hidden AND inert — the inert-pair
|
||||
contract), lock the app, focus the token input. The gate is the
|
||||
only interactive surface while visible: the lock root is inert,
|
||||
so Tab never reaches the locked app (the composer on the shell,
|
||||
the content on the viewer). */
|
||||
if (gate) {
|
||||
gate.hidden = false;
|
||||
gate.inert = false;
|
||||
}
|
||||
if (lockRoot) lockRoot.inert = true;
|
||||
if (input) input.focus();
|
||||
if (!form) return; // no gate markup — nothing to bind
|
||||
|
||||
/* Form submit: preventDefault → POST /api/token-auth → 204: cache
|
||||
the token, invalidate + re-fetch the whoami cache (the documented
|
||||
choice: clear the module cache — a direct re-fetch would leave
|
||||
header.js's boot-fired anonymous cache stale for the header
|
||||
re-boot), role "user" → hide the gate, unlock, onAuthed(), and
|
||||
re-boot the shared header for the new role. 401 / failure: the
|
||||
role=alert error line, the input cleared + re-focused, the
|
||||
button re-enabled (never stale, PLAN §7.4). */
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
void (async () => {
|
||||
const token = (input && input.value ? input.value : "").trim();
|
||||
if (!token || (submit && submit.disabled)) return;
|
||||
if (error) error.hidden = true;
|
||||
if (submit) submit.disabled = true; // one auth at a time
|
||||
const ok = await tryTokenAuth(token).catch(() => false);
|
||||
if (!ok) {
|
||||
showError();
|
||||
if (submit) submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
storeToken(token); // try/catch — private mode: caching is a no-op
|
||||
resetWhoami(); // the boot-fired whoami says anonymous — stale
|
||||
const after = await fetchWhoami(); // the single fresh post-auth request
|
||||
if (!after.authenticated) {
|
||||
// token-auth said 204 but whoami still says anonymous (the
|
||||
// session cookie did not land): drop the cache just written
|
||||
// and let the user retry. (role "admin" here — an admin
|
||||
// signed in from another tab in the meantime — is
|
||||
// authenticated: the gate's job is to let them through.)
|
||||
removeToken();
|
||||
showError();
|
||||
if (submit) submit.disabled = false;
|
||||
return;
|
||||
}
|
||||
hideGate();
|
||||
if (lockRoot) lockRoot.inert = false;
|
||||
onAuthed();
|
||||
// A mid-page login must not leave the anonymous header state
|
||||
// (Sign in visible / Sign out hidden): re-boot the shared
|
||||
// header on the fresh post-auth whoami (idempotent — it just
|
||||
// re-toggles the same controls).
|
||||
void initSharedHeader();
|
||||
})();
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -44,6 +44,19 @@
|
||||
* exports (zero extra requests; the flag decides whether the note
|
||||
* list loads at all, the Sources-page gate pattern).
|
||||
*
|
||||
* Phase 77 (task 02) — the re-show refresh: 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 Tuning nav link, or back/forward) — the first show (mount)
|
||||
* and boot never (the mount's own load is the first fetch). This
|
||||
* module listens on root and re-runs `loadNotes()` (renderNotes
|
||||
* already clears the list, so a re-call replaces it). A FAILED
|
||||
* refresh keeps the last rendered list — loadNotes's documented
|
||||
* contract (progressive enhancement, never a blanked panel). The
|
||||
* listener is armed only in the ADMIN branch, after the whoami gate
|
||||
* passes: anonymous gets the empty-state view and never fetches
|
||||
* (the phase-27 gate).
|
||||
*
|
||||
* Anonymous-safe (phase 27 task 03, unchanged in the shell): the
|
||||
* header hides the "Tuning" nav link for anonymous visitors; a DIRECT
|
||||
* anonymous URL still gets a safe view — loadNotes() only runs when
|
||||
@@ -348,5 +361,15 @@ export async function mount(root) {
|
||||
* (zero extra requests). An anonymous visitor gets the view frame
|
||||
* with the empty state, and the create form 403s gracefully on
|
||||
* submit if one tries. */
|
||||
if (await fetchIsAdmin()) loadNotes(); // phase 27: the list is admin-only
|
||||
/* Phase 77 (task 02): a user-initiated re-show of this already-
|
||||
mounted view makes the router dispatch bor:view-refresh on the
|
||||
section — re-run loadNotes then (renderNotes clears the list
|
||||
first, so a re-call replaces it; a failed refresh keeps the last
|
||||
rendered list — loadNotes's documented contract). Armed ONLY
|
||||
here, after the whoami gate passed: anonymous never fetches
|
||||
(the phase-27 gate). */
|
||||
if (await fetchIsAdmin()) {
|
||||
root.addEventListener("bor:view-refresh", () => loadNotes());
|
||||
loadNotes(); // phase 27: the list is admin-only
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user