feat(auth): single-admin password login (signed cookie) — gate tuning + Sources catalog, keep chat and document viewer public
This commit is contained in:
+58
-5
@@ -126,8 +126,11 @@ function announceSteering(message) {
|
||||
|
||||
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
||||
sources' .msg-meta row when it exists (role=list → the button joins as
|
||||
a listitem so ARIA stays valid); otherwise creates a plain meta row. */
|
||||
a listitem so ARIA stays valid); otherwise creates a plain meta row.
|
||||
Phase 16: anonymous visitors never get the button — this single guard
|
||||
covers both fresh turns and the phase-14 restore path. */
|
||||
function appendTuneButton(wrap) {
|
||||
if (!isAdmin) return; // phase 16: tuning is admin-only
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
let meta = body.querySelector(".msg-meta");
|
||||
@@ -656,6 +659,50 @@ function rememberBrainTurn(rawText, meta) {
|
||||
* empty state (suggestions included). Ignored while a turn is in flight —
|
||||
* a live stream must not be hijacked. Confirmation reuses the existing
|
||||
* #send-status live region (aria-live=polite). */
|
||||
/* ---------- single-admin auth (phase 16, A10 revised) ----------
|
||||
*
|
||||
* /api/whoami decides the header: anonymous → the Sign in link and NO
|
||||
* tuning surface at all — the Tuning toggle + panel are removed from the
|
||||
* DOM (the story says "absent", not just hidden), /api/steering is never
|
||||
* fetched, and appendTuneButton injects nothing (new or restored
|
||||
* messages). Admin → Sign out (POST /api/logout + reload) + the full
|
||||
* phase-15 UI. Whoami is awaited BEFORE the phase-14 restore, so restored
|
||||
* brain bubbles never flash a Tune button that should not be there.
|
||||
*/
|
||||
const signInLink = document.querySelector("#sign-in-link");
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
let isAdmin = false;
|
||||
|
||||
function applyAuthState() {
|
||||
if (signInLink) signInLink.hidden = isAdmin;
|
||||
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
||||
if (!isAdmin && steeringToggle) {
|
||||
steeringToggle.remove();
|
||||
steeringPanel?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuthState() {
|
||||
try {
|
||||
const r = await fetch("/api/whoami");
|
||||
if (r.ok) isAdmin = (await r.json()).authenticated === true;
|
||||
} catch {
|
||||
isAdmin = false; // API unreachable: anonymous-safe defaults
|
||||
}
|
||||
applyAuthState();
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
@@ -799,7 +846,13 @@ input.addEventListener("keydown", (e) => {
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
restoreConversation(); // phase 14: the conversation comes back as left
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
loadSteering(); // phase 15: tuning notes (panel + count badge)
|
||||
/* Boot: auth state FIRST — it decides whether the restored conversation
|
||||
gets Tune buttons and whether the steering UI exists at all (phase 16).
|
||||
Phase 14: the conversation then comes back exactly as left. */
|
||||
(async () => {
|
||||
await loadAuthState();
|
||||
restoreConversation();
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/* 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.
|
||||
*
|
||||
* No CDN, no state in this file: the signed cookie is the whole session.
|
||||
* All DOM ids match frontend/login.html.
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function alreadySignedIn() {
|
||||
try {
|
||||
const r = await fetch("/api/whoami");
|
||||
if (!r.ok) return false;
|
||||
return (await r.json()).authenticated === true;
|
||||
} catch {
|
||||
return false; // API unreachable: stay on the form — submit will explain
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
});
|
||||
|
||||
/* Already the admin? Skip the form and go straight to the target. */
|
||||
(async () => {
|
||||
if (await alreadySignedIn()) {
|
||||
window.location.replace(safeNext());
|
||||
return;
|
||||
}
|
||||
passwordInput.focus();
|
||||
})();
|
||||
@@ -9,10 +9,24 @@
|
||||
const tbody = document.querySelector("#docs-tbody");
|
||||
const emptyEl = document.querySelector("#sources-empty");
|
||||
const tableWrap = document.querySelector(".table-wrap");
|
||||
const statCards = document.querySelector("#stat-cards");
|
||||
const gateEl = document.querySelector("#sources-gate");
|
||||
const statDocs = document.querySelector("#stat-docs");
|
||||
const statChunks = document.querySelector("#stat-chunks");
|
||||
const statLast = document.querySelector("#stat-last");
|
||||
|
||||
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||||
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||||
* catalog is admin-only. The document viewer itself stays public (the
|
||||
* soft rule), so the gate copy points at what keeps working. */
|
||||
async function isAdmin() {
|
||||
try {
|
||||
const r = await fetch("/api/whoami");
|
||||
if (r.ok) return (await r.json()).authenticated === true;
|
||||
} catch { /* API unreachable: anonymous-safe gate */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
function fmtDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
@@ -97,4 +111,15 @@ function showEmpty() {
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
}
|
||||
|
||||
loadDocs();
|
||||
(async () => {
|
||||
if (!(await isAdmin())) {
|
||||
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||||
if (statCards) statCards.hidden = true;
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
if (emptyEl) emptyEl.hidden = true;
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadDocs();
|
||||
})();
|
||||
|
||||
@@ -241,6 +241,33 @@ body::after {
|
||||
whole control below 640px. */
|
||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* Phase 16: header auth controls (Sign in link / Sign out button) — the
|
||||
same ghost pill as New chat, so the bar keeps one visual language.
|
||||
ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft ≈6.9:1.
|
||||
Icon-only below 640px (aria-labels/labels keep the accessible names);
|
||||
≥44px touch target at every width. Exactly one is ever visible. */
|
||||
.auth-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.auth-link:disabled { opacity: 0.6; cursor: wait; }
|
||||
.auth-link svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* "Tuning" toggle (phase 15): ghost pill like New chat + a mono count
|
||||
badge (brand-ink on brand-soft ≈6.9:1). The label is visually-hidden
|
||||
(not removed) below 640px so the accessible name keeps the word.
|
||||
@@ -724,6 +751,65 @@ body::after {
|
||||
.kb-banner.is-error { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||
.kb-banner svg { width: 18px; height: 18px; flex: 0 0 auto; display: block; }
|
||||
|
||||
/* ---------- Login page (phase 16) ---------- */
|
||||
/* Centered card in the standard frame: one admin, one password. */
|
||||
.login-shell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2rem 2rem 2.25rem;
|
||||
}
|
||||
.login-card h1 { margin: 0 0 0.4rem; font-size: 1.6rem; }
|
||||
.login-sub { margin: 0 0 1.5rem; color: var(--ink-soft); }
|
||||
#login-form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
#login-password {
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--ink);
|
||||
background: #0d1120;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.55rem 0.75rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
#login-password:focus-visible { border-color: var(--brand); }
|
||||
/* Brand button: dark ink on brand 5.2:1 (never white on brand). */
|
||||
.login-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--brand);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
.login-submit:hover:not(:disabled) { background: #7d88f5; }
|
||||
.login-submit:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* Login failure (role=alert): err pair ≈9.1:1. */
|
||||
.login-error {
|
||||
margin: 0.9rem 0 0;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Sources page ---------- */
|
||||
.sources-shell {
|
||||
display: flex;
|
||||
@@ -755,6 +841,39 @@ body::after {
|
||||
.stat-value-sm { font-size: 1.15rem; font-weight: 700; }
|
||||
.stat-label { color: var(--ink-soft); font-size: 0.88rem; font-weight: 600; }
|
||||
|
||||
/* Phase 16: anonymous sign-in gate — the designed replacement for the
|
||||
catalog (stat cards + table) until the admin signs in. */
|
||||
.sources-gate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 2.5rem 1.75rem;
|
||||
}
|
||||
.sources-gate-glyph { color: var(--brand-ink); width: 44px; height: 44px; }
|
||||
.sources-gate-glyph svg { width: 44px; height: 44px; display: block; }
|
||||
.sources-gate h2 { margin: 0.6rem 0 0.3rem; font-size: 1.4rem; }
|
||||
.sources-gate-sub { margin: 0 auto; max-width: 30rem; color: var(--ink-soft); }
|
||||
.sources-gate-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sources-gate-link:hover { background: #7d88f5; }
|
||||
|
||||
.table-wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
@@ -1005,6 +1124,11 @@ body::after {
|
||||
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||
.auth-link { padding: 0.4rem 0.55rem; }
|
||||
.auth-label { display: none; }
|
||||
.auth-link svg { display: block; }
|
||||
.steering-toggle { padding: 0.4rem 0.55rem; }
|
||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||
word "Tuning" next to the count badge. */
|
||||
|
||||
Reference in New Issue
Block a user