Files
brain-of-reese/frontend/assets/login.js
T
ducoterra 1f0e4c6bb9
Build and Push Containers / build-and-push-app (push) Successful in 1m53s
Build and Push Containers / build-and-push-db (push) Successful in 11s
fix(ui): bind the shared header controls on explicit init, not at module import
header.js's control bindings (sign-out, the mobile hamburger, the
SINGLE New chat button) ran at module import. The Containerfile stage-1
build inlines header.js into every bundle that imports it (the shell's
app.js, token-gate.js and the router's lazy views), so the shell page
registered the #nav-toggle click handler twice, and two toggle handlers
cancel each other — one tap = open + close = the mobile menu dead in
the deployed image only. The dev tree's single ESM instance (and every
test that runs against it) never showed it; a lazy view load adding a
THIRD copy made the menu work again, which is why the failure looked
state-dependent (chat cold boot dead, /sources.html alive).

- header.js: the three bindings move into an exported
  bindSharedHeaderControls(), guarded by a marker on <body> (NOT module
  state — every bundle copy has its own function instance), so later
  bundle copies and repeated inits (the token gate's mid-page header
  re-boot) are no-ops; header.js is now side-effect-free at top level,
  which also lets esbuild tree-shake the dead copies out of the bundles
  that do not need them (the token-gate bundle no longer carries the
  binding code at all)
- app.js / login.js / shared.js / document.js: call
  bindSharedHeaderControls() once at module top — import-time parity,
  unconditional (no async boot path to miss); doc-edit.js ships no
  header controls and calls nothing
- unit: tests/unit/test_header_bindings_once_per_document.py pins the
  contract — the init export, the document-level idempotency marker,
  all three bindings inside the init, NO top-level addEventListener
  remaining, and exactly one module-top call in each header-carrying
  page script; stale import-time docstrings in the legacy header pins
  updated to the new contract

Verified: full unit + integration suite (1746 passed), the hamburger /
pinned-composer / smoke E2E stories green in isolation, ruff + pyright
clean. Containerfile-equivalent esbuild 0.25.5 rebuild probed in
Chromium: exactly ONE #nav-toggle click listener on chat cold boot,
/sources.html and login.html, and a touch tap opens the menu in all
three states (pre-fix production: two listeners on cold boot = dead,
three on sources = alive).
2026-09-08 22:31:45 -04:00

103 lines
3.9 KiB
JavaScript

/* Brain of Reese — admin sign-in (phase 16, A10 revised).
*
* One admin, one password. On submit → POST /api/login: 204 sets the
* signed session cookie and we redirect to `?next` (same-origin relative
* URLs only — "/…" but never "//host" or an absolute URL; default
* /sources.html). A 401 keeps the form and announces through the
* role=alert error region. On load, /api/whoami already says admin →
* straight to `next`, no form.
*
* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
* whoami check runs on the shared header module's cached promise
* (assets/header.js) — one request per page, and the module's
* initSharedHeader() settles the login page's FULL shared header —
* the SAME bar as every other page (nav incl. the admin-only links,
* Tuning toggle, Sync, New chat, the auth pair).
*
* No CDN, no state in this file: the signed cookie is the whole session.
* All DOM ids match frontend/login.html.
*/
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
const form = document.querySelector("#login-form");
const passwordInput = document.querySelector("#login-password");
const submitBtn = document.querySelector("#login-submit");
const errorEl = document.querySelector("#login-error");
const DEFAULT_NEXT = "/sources.html";
/* Same-origin relative URLs only: honor `?next=/…`, reject anything that
would leave the origin (protocol-relative "//…" or absolute). */
function safeNext() {
const next = new URLSearchParams(window.location.search).get("next") || DEFAULT_NEXT;
return next.startsWith("/") && !next.startsWith("//") ? next : DEFAULT_NEXT;
}
function showError(message) {
errorEl.textContent = message;
errorEl.hidden = false;
submitBtn.disabled = false;
passwordInput.focus();
passwordInput.select();
}
/* Phase 19: the shared header module IS the whoami call site (cached
* promise, anonymous-safe) — same result as the private fetch it
* replaces: a network failure stays on the form (submit will explain).
*/
function alreadySignedIn() {
return fetchIsAdmin();
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.hidden = true;
errorEl.textContent = "";
submitBtn.disabled = true;
try {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: passwordInput.value }),
});
if (r.status === 204) {
// Session cookie set — off to the requested page.
window.location.replace(safeNext());
return;
}
// One generic failure (401); anything else is a server-side surprise.
const detail =
r.status === 401
? "Invalid password — try again."
: `Sign-in failed (HTTP ${r.status}) — try again.`;
showError(detail);
} catch {
showError("Could not reach the server — try again.");
}
});
/* The bar settles in BOTH branches (phase 34 task 05 — the login page
* carries the FULL shared header, so a signed-in admin who lands here
* gets the settled admin bar for the frame before the redirect, not
* the ship-hidden state): the whoami promise is already settled by
* this point, so initSharedHeader adds no request and no delay, and
* the phase-16 redirect itself is unchanged. For anonymous visitors it
* settles the reduced bar: Sign in visible, the admin-only controls
* stay hidden, the steering panel removed (the navbar toggle no longer
* ships at all — removed at owner request, 2026-08-28). */
(async () => {
const admin = await alreadySignedIn();
await initSharedHeader();
if (admin) {
window.location.replace(safeNext());
return;
}
passwordInput.focus();
})();