Files
bookhoard/web/src/header.ts
T
John O'Keefe 7ea84043fa
Release / build-and-push (push) Successful in 3m9s
fix(auth): logout actually signs out — clear the HttpOnly session cookie
Logout wiped localStorage but never cleared the token cookie (HttpOnly —
only a server response can) and never called the logout API, leaving the
refresh token live for 30 days and every cookie-authenticated SSR page
accessible: the fake-signout bug. Reproduced on prod and dev; unrelated
to RLS (it sits behind authentication) and long-standing.

- /api/auth/logout now ALWAYS clears the session cookie (Set-Cookie
  empty + Max-Age=-1, HttpOnly/Path/SameSite mirroring login) — even a
  malformed body must sign the browser out; refresh-token revocation
  unchanged.
- The header logout button (and the rules-page copy) now POST to the
  API with the stored refresh token BEFORE clearing local state and
  redirecting — best effort: local cleanup never blocks on API failure.
- toast 401 handler fixed: it cleared 'refreshToken' but storage uses
  'refresh_token' — the real key.
- Regression test TestLogoutClearsSession: real login → logout →
  response deletes the cookie; logged-out refresh token can't mint
  access tokens; body-less logout still clears.

Also carries regenerated artifacts from the confirm-modal and
episode-list work (header_templ/podcast_detail_templ/style.css).
2026-10-29 22:01:32 -04:00

232 lines
7.9 KiB
TypeScript

import { Alpine } from "./alpine";
// Side-effect import: registers the headerSearch Alpine component that
// owns the header dropdown's client-local state (open/close/keyboard).
// Without it the bundler tree-shakes the module away and the search
// input silently stops opening the dropdown.
import "./search";
import {
initializeTheme,
changeTheme,
changeWoodPaneling,
loadWoodPaneling,
revealCurrentTheme,
updateWoodPanelingIndicators,
} from "./theme";
import { getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage";
import { addListener, removeListener } from "./websocket";
// Real sign-out: the HttpOnly session cookie can only be cleared by a
// server response, and the refresh token (30d) must be revoked —
// localStorage-only "logout" left the cookie session alive for days
// (the fake-signout bug). Best effort: local state clears and the
// redirect happens even if the API call fails.
const logout = async (): Promise<void> => {
try {
await fetch("/api/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: getRefreshToken() ?? "" }),
});
} catch {
// keep going: local cleanup + redirect must never be blocked
}
localStorage.removeItem("token");
localStorage.removeItem("user");
removeRefreshToken();
window.location.href = "/";
};
const restoreLibrarySelection = (): void => {
const savedLibrary = getSelectedLibrary();
if (!savedLibrary) return;
const dropdownSelectors = ["#library-select", "#selected-library", "#library_id"];
for (const selector of dropdownSelectors) {
const dropdown = document.querySelector(selector) as HTMLSelectElement;
if (dropdown) {
const option = dropdown.querySelector(`option[value="${savedLibrary}"]`);
if (option) {
// SSR pages already render the select with the stored library
// (setSelectedLibrary keeps the cookie in sync and resolveLibrary
// renders from it). Re-firing `change` here made every page load
// re-run the page's switch handler — which re-fetches content at
// hardcoded page 1 / offset 0 and stomps URL-tracked state (this
// broke the /series paginator: page 2 snapped back to page 1).
// Only re-fire when the select actually drifted from storage.
if (dropdown.value === savedLibrary) return;
dropdown.value = savedLibrary;
dropdown.dispatchEvent(new Event("change", { bubbles: true }));
console.log("Restored library selection:", savedLibrary);
return;
}
}
}
};
const initializeScanListener = function (this: any) {
// Restore state on page load: a scan that started before this page
// was opened (or a refresh mid-scan) emits no websocket event until
// its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => {
try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) return;
const data = await resp.json();
if (data.scanning) {
this.scanning = true;
this.scanProgress = Math.round((data.progress || 0) * 100);
}
} catch {
// Offline or endpoint unavailable — websocket events still drive
// the state once they arrive.
}
};
restoreActiveScan();
const handler = (message: any) => {
switch (message.type) {
case "scan_progress":
this.scanning = true;
this.scanProgress = Math.round((message.data.progress || 0) * 100);
this.scanNewItems = message.data.new_items || 0;
this.scanFilesScanned = message.data.files_scanned || 0;
break;
case "scan_complete":
this.scanning = false;
this.scanProgress = 100;
this.scanNewItems = message.data?.new_items || this.scanNewItems;
window.dispatchEvent(
new CustomEvent("bookhoard:scan-complete", {
detail: message.data,
}),
);
setTimeout(() => {
this.scanProgress = 0;
this.scanNewItems = 0;
this.scanFilesScanned = 0;
}, 3000);
break;
case "scan_error":
this.scanning = false;
this.scanProgress = 0;
break;
case "scan_cancelled":
// A cancelled scan is not an error: stop the spinner quietly.
this.scanning = false;
this.scanProgress = 0;
break;
}
};
addListener(handler);
const headerEl = document.querySelector("[x-data='header']");
if (headerEl) {
const observer = new MutationObserver(() => {
if (!document.contains(headerEl)) {
removeListener(handler);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
};
export { logout, restoreLibrarySelection };
Alpine.data("header", () => ({
init() {
// Shared panel state: one open panel across all sidebar panels. Default
// to the persisted panel (or the admin section on /admin pages).
try {
const v = localStorage.getItem("sidebar-open-panel");
this.openPanel =
v !== null
? v === ""
? null
: v
: window.location.pathname.startsWith("/admin")
? "admin"
: null;
} catch {
this.openPanel = window.location.pathname.startsWith("/admin") ? "admin" : null;
}
this.initializeTheme();
this.loadWoodPaneling();
this.updateWoodPanelingIndicators();
this.restoreLibrarySelection();
this.initializeScanListener();
// Persist the sidebar's open panel across navigations (was an inline
// x-init $watch on the panel element).
this.$watch("openPanel", (v: unknown) => {
try {
localStorage.setItem("sidebar-open-panel", v === null ? "" : String(v));
} catch {
/* private-mode localStorage failures are non-fatal */
}
});
},
openPanel: null as string | null,
// Shadow copy for the root-level toggleAppearancePanel: the sidebar shell's
// own themeMode (initialized from the server) is the one that matters at
// runtime — the merged Alpine scope reads the nearest declaration.
themeMode: "",
mobileMenuOpen: false,
// Shared panel toggles: invoked from the sidebar shell's scope, so `this`
// is the merged data stack (shell themeMode + root openPanel).
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
logout,
initializeTheme,
changeTheme,
changeWoodPaneling,
loadWoodPaneling,
revealCurrentTheme,
updateWoodPanelingIndicators,
restoreLibrarySelection,
initializeScanListener,
scanning: false,
scanProgress: 0,
scanNewItems: 0,
scanFilesScanned: 0,
}));
// Sidebar shell: owns the appearance panel's theme-mode state. The shared
// openPanel lives on the header root (one open panel across all panels).
// Registered (not inline x-data) so its methods work under the CSP-safe
// Alpine build, which cannot evaluate arbitrary inline sentences.
Alpine.data("sidebarShell", (initialThemeMode: string) => ({
themeMode: initialThemeMode,
toggleAppearancePanel() {
this.openPanel = this.openPanel === "appearance" ? null : "appearance";
if (this.openPanel === "appearance") {
this.$nextTick(() => this.revealCurrentTheme(this.themeMode));
}
},
setThemeMode(mode: string) {
this.themeMode = mode;
this.$nextTick(() => this.revealCurrentTheme(mode));
},
selectTheme(name: string) {
changeTheme(name);
this.openPanel = null;
},
}));