109 lines
5.0 KiB
JavaScript
109 lines
5.0 KiB
JavaScript
/* Brain of Reese — shared header module (phase 19).
|
|
*
|
|
* Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in
|
|
* vanish — the user expects ONE consistent bar on every page. This module
|
|
* is the single owner of the shared header controls:
|
|
*
|
|
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
|
* decided by /api/whoami at load);
|
|
* • the admin-only nav links — "Sources" (#nav-sources) and "Tuning"
|
|
* (#nav-tuning, on the Global Tuning page, phase 27) — phase 19 UX
|
|
* revision (owner permission 2026-08-23): hidden for anonymous on
|
|
* every page that has a nav (chat, sources, tuning, login),
|
|
* revealed for admin. The links SHIP hidden in the HTML
|
|
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
|
* spirit), so no anonymous user ever sees one for a frame; and the
|
|
* Sources page's "Sync sources" button (#sync-btn, phase 32) —
|
|
* the same ship-hidden / reveal-for-admin contract on the SAME
|
|
* cached whoami (one fetch, no extra request);
|
|
* • the sign-out click binding (POST /api/logout → reload) — moved
|
|
* here from app.js so there is exactly one implementation;
|
|
* • clearChatStorage() — the phase-14 conversation key, for the
|
|
* New Chat buttons on the NON-CHAT pages (sources / document
|
|
* viewer / tuning): a new chat means going to the chat, fresh.
|
|
*
|
|
* Every page loads this module (type="module", before its page script)
|
|
* and its page script calls initSharedHeader() once at boot. init…
|
|
* toggles ONLY the controls that exist on the page — a missing element
|
|
* is a no-op, which is how the login page reuses the module without
|
|
* gaining chat controls (no #new-chat-btn / #sign-in-link /
|
|
* #sign-out-btn in its markup → none appear).
|
|
*
|
|
* 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
|
|
* 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.
|
|
*
|
|
* 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 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);
|
|
}
|
|
return adminPromise;
|
|
}
|
|
|
|
/* 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
|
|
cached promise makes both awaits the same single request). */
|
|
export async function initSharedHeader() {
|
|
const admin = await fetchIsAdmin();
|
|
const signIn = document.querySelector("#sign-in-link");
|
|
if (signIn) signIn.hidden = admin;
|
|
const signOut = document.querySelector("#sign-out-btn");
|
|
if (signOut) signOut.hidden = !admin;
|
|
const navSources = document.querySelector("#nav-sources");
|
|
if (navSources) navSources.hidden = !admin;
|
|
// Phase 27: the Global Tuning page's own nav link — admin-only, the
|
|
// same ship-hidden / reveal-for-admin contract as the Sources link.
|
|
const navTuning = document.querySelector("#nav-tuning");
|
|
if (navTuning) navTuning.hidden = !admin;
|
|
// Phase 32: the Sources page's "Sync sources" button — admin-only,
|
|
// revealed on this same cached whoami (anonymous users never see it).
|
|
const syncBtn = document.querySelector("#sync-btn");
|
|
if (syncBtn) syncBtn.hidden = !admin;
|
|
return admin;
|
|
}
|
|
|
|
/* Remove the phase-14 conversation key — same key + fail-silence
|
|
contract as app.js's clearStoredConversation: private mode or a
|
|
storage error is swallowed, the navigation still happens. */
|
|
export function clearChatStorage() {
|
|
try {
|
|
localStorage.removeItem("bor.chat.v1");
|
|
} catch {
|
|
/* nothing was stored */
|
|
}
|
|
}
|
|
|
|
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
|
import, so every page that loads header.js gets it exactly once.
|
|
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). */
|
|
const signOutBtn = document.querySelector("#sign-out-btn");
|
|
if (signOutBtn) {
|
|
signOutBtn.addEventListener("click", async () => {
|
|
signOutBtn.disabled = true;
|
|
try {
|
|
await fetch("/api/logout", { method: "POST" });
|
|
} catch {
|
|
/* the reload resets the UI either way */
|
|
}
|
|
window.location.reload();
|
|
});
|
|
}
|