feat(ui): shared header — Sign in/Sign out and New Chat on every page; hide the Sources nav link from anonymous users
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
/* 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 "Sources" nav link (#nav-sources) — phase 19 UX revision
|
||||
* (owner permission 2026-08-23): hidden for anonymous on every page
|
||||
* that has a nav (chat, sources, login), revealed for admin. The
|
||||
* link SHIPS hidden in the HTML (anonymous-safe default — the
|
||||
* phase-16 "absent, not hidden" spirit), so no anonymous user ever
|
||||
* sees it for a frame;
|
||||
* • 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): 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;
|
||||
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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user