diff --git a/tests/ui/knob_matrix.spec.ts b/tests/ui/knob_matrix.spec.ts index 39fc173e..7c5a03c2 100644 --- a/tests/ui/knob_matrix.spec.ts +++ b/tests/ui/knob_matrix.spec.ts @@ -159,6 +159,31 @@ test("theme picker: every click persists and flips the body class", async ({ pag await page.locator('.theme-btn[data-theme="tokyo-night"]').click(); await back; expectNoNewConsoleErrors(); + + // THE STRAND-CLASS REGRESSION PIN: the global 401 interceptor can + // clear the localStorage bearer (session rotation); theme saves must + // ride the token COOKIE (middleware TokenLookup checks cookie first) + // — never gated on localStorage ("can no longer change themes", + // 2026-10-31: the save early-returned without a bearer and every + // reload re-rendered the old server theme). + await page.evaluate(() => localStorage.removeItem("token")); + await appearance.click(); + const putNord = page.waitForRequest( + (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), + ); + await page.locator('.theme-btn[data-theme="nord"]').click(); + expect((await putNord).postDataJSON()).toEqual({ theme: "nord" }); + await expect.poll(() => page.locator("body").getAttribute("class")).toContain("theme-nord"); + + // Restore the default — also via cookie, proving persistence works + // without any localStorage bearer at all. + await appearance.click(); + const putBack = page.waitForRequest( + (r) => r.method() === "PUT" && r.url().includes("/api/auth/theme"), + ); + await page.locator('.theme-btn[data-theme="tokyo-night"]').click(); + await putBack; + expectNoNewConsoleErrors(); }); test("pencil glyph vs card body: modal stays, card navigates", async ({ page }) => { diff --git a/web/src/admin.ts b/web/src/admin.ts index 1353d9a2..33cda338 100644 --- a/web/src/admin.ts +++ b/web/src/admin.ts @@ -1,5 +1,6 @@ import { confirmDialog } from "./confirm"; import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; import { showToast } from "./toast"; // Admin API calls authenticate with the Bearer token stashed at login, @@ -7,23 +8,11 @@ import { showToast } from "./toast"; // web session can still have an empty/stale localStorage token. User- // triggered actions must never die silently in that state (the // /admin/archived purge + delete buttons were invisible dead-ends: the -// missing-token return fired before the confirm dialog): requireToken -// fails loudly instead. -function requireToken(): string | null { - const token = localStorage.getItem("token"); - if (!token) { - showToast("Session expired — sign out and back in, then retry", "error"); - } - return token; -} - async function scanAllLibraries(): Promise { - const token = requireToken(); - if (!token) return; try { const libsResp = await fetch("/api/libraries", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!libsResp.ok) { @@ -46,7 +35,7 @@ async function scanAllLibraries(): Promise { const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, { method: "POST", headers: { - Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, body: JSON.stringify({ force: true }), @@ -125,7 +114,6 @@ function showScanProgress(jobIds: string[], libraryNames: Record let scanPollInterval: ReturnType | undefined = undefined; function pollScanProgress(jobIds: string[], _libraryNames: Record): void { - const token = localStorage.getItem("token"); const startTime = Date.now(); const cancelledJobs = new Set(); @@ -140,7 +128,7 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record { - const token = requireToken(); - if (!token) return; const btn = document.querySelector(`[data-cancel-job="${jobId}"]`); if (btn) { @@ -253,7 +239,7 @@ async function cancelScan(jobId: string): Promise { try { const resp = await fetch(`/api/scanner/cancel/${jobId}`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); @@ -273,12 +259,10 @@ async function cancelScan(jobId: string): Promise { // list so it also covers jobs this page never started (e.g. started // before a refresh), then cancels each one. async function cancelAllScans(): Promise { - const token = requireToken(); - if (!token) return; try { const resp = await fetch("/api/scanner/active", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) { throw new Error("Failed to list active scans"); @@ -303,13 +287,10 @@ function setCancelAllVisible(visible: boolean): void { // widget and re-attach polling. Websocket start-events only reach pages // that were already open when the scan began. async function checkActiveScans(): Promise { - // Passive page-load probe, not a user action: stay silent when the API - // token is absent (requireToken's toast would fire on every page visit). - const token = localStorage.getItem("token"); - if (!token) return; + // Passive page-load probe, not a user action — cookie auth carries it. try { const resp = await fetch("/api/scanner/active", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) return; const data = await resp.json(); @@ -323,7 +304,7 @@ async function checkActiveScans(): Promise { const libraryNames: Record = {}; try { const libsResp = await fetch("/api/libraries", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (libsResp.ok) { const libsData = await libsResp.json(); @@ -389,13 +370,10 @@ function hideScanProgress(): void { async function loadWatchStatus(): Promise { // Passive status fetch inside the admin component's init, not a user // action: stay silent when the API token is absent (requireToken's - // toast would fire on every page visit). - const token = localStorage.getItem("token"); - if (!token) return; try { const response = await fetch("/api/scanner/watch/status", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { const data = await response.json(); @@ -420,8 +398,6 @@ function stopScanStatusPolling(): void { // window for the library admin page's inline button; reading history is // deleted with the rows, so a confirm dialog guards it. async function purgeArchivedItems(): Promise { - const token = requireToken(); - if (!token) return; if ( !(await confirmDialog( @@ -434,7 +410,7 @@ async function purgeArchivedItems(): Promise { try { const resp = await fetch("/api/media-items/purge-archived", { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); @@ -452,12 +428,10 @@ async function purgeArchivedItems(): Promise { // its file to return (POST /api/media-items/:id/unarchive). If the file is // still gone the next scan hides it again. async function unarchiveItem(id: string): Promise { - const token = requireToken(); - if (!token) return; try { const resp = await fetch(`/api/media-items/${id}/unarchive`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); @@ -473,8 +447,6 @@ async function unarchiveItem(id: string): Promise { // Permanently delete one archived item and its reading history // (DELETE /api/media-items/:id). async function deleteArchivedItem(id: string): Promise { - const token = requireToken(); - if (!token) return; if ( !(await confirmDialog( "Permanently delete this item? Its reading progress, notes, and highlights will be lost.", @@ -485,7 +457,7 @@ async function deleteArchivedItem(id: string): Promise { try { const resp = await fetch(`/api/media-items/${id}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); @@ -502,8 +474,6 @@ async function deleteArchivedItem(id: string): Promise { // same content hash (POST /api/media-items/:id/merge), then remove the // archived entry. Covers copy-then-delete-later moves between libraries. async function mergeArchivedItem(sourceId: string, targetId: string): Promise { - const token = requireToken(); - if (!token) return; if ( !(await confirmDialog( "Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.", @@ -514,10 +484,7 @@ async function mergeArchivedItem(sourceId: string, targetId: string): Promise ({ // The qf engine then drives it everywhere — shelf, collections page, // play chains — with zero re-authoring. async saveFilterAsCollection() { - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } const nameInput = document.getElementById("save-filter-name") as HTMLInputElement | null; const name = nameInput?.value?.trim() ?? ""; if (!name) { @@ -203,7 +199,7 @@ Alpine.data("bookshelf", () => ({ try { const response = await fetch("/api/collections", { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { ...authHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ name, filter_tree: tree }), }); if (response.ok) { @@ -231,16 +227,11 @@ Alpine.data("bookshelf", () => ({ showToast(`Loading filter: ${filterName}...`, "info"); - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } try { // Fetch filter details from API const response = await fetch(`/api/saved-filters/${filterId}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!response.ok) { @@ -320,16 +311,11 @@ Alpine.data("bookshelf", () => ({ if (!confirm("Are you sure you want to delete this filter?")) return; - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } try { const response = await fetch(`/api/saved-filters/${filterId}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { diff --git a/web/src/collection-rules.ts b/web/src/collection-rules.ts index 94636058..7df7ab38 100644 --- a/web/src/collection-rules.ts +++ b/web/src/collection-rules.ts @@ -8,6 +8,7 @@ // arrays preload as their CONVERTED tree (RulesToFilterTree, rendered // server-side) and upgrade at the first Apply. +import { authHeaders } from "./storage"; import { Alpine } from "./alpine"; import { showToast } from "./toast"; @@ -22,11 +23,6 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi // absent at init timing. Alpine.store("collectionTreeApply", { hook: async (wire: string) => { - const token = localStorage.getItem("token"); - if (!token) { - showToast("Not authenticated", "error"); - return; - } const name = pageCollectionName(); if (!name) { showToast("Cannot determine the collection name", "error"); @@ -35,7 +31,7 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi try { const response = await fetch(`/api/collections/${collectionId}`, { method: "PUT", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { ...authHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ name, filter_tree: JSON.parse(wire) }), }); if (response.ok) { diff --git a/web/src/collections.ts b/web/src/collections.ts index d4eb4458..9232826c 100644 --- a/web/src/collections.ts +++ b/web/src/collections.ts @@ -1,4 +1,5 @@ import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; import { showToast } from "./toast"; import { createWebSocket } from "./websocket"; import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; @@ -50,7 +51,9 @@ function initializeCollectionWebSocket(): void { function setupHTMXAuth(): void { document.body.addEventListener("htmx:configRequest", function (evt: Event) { const token = localStorage.getItem("token"); - (evt as any).detail.headers["Authorization"] = `Bearer ${token}`; + // Only attach a bearer when localStorage has one — htmx sends + // cookies regardless, and "Bearer null" is never needed. + if (token) (evt as any).detail.headers["Authorization"] = `Bearer ${token}`; }); } @@ -299,12 +302,10 @@ function toggleSelection(this: any, id: string): void { } async function doRemoveBook(id: string): Promise { - const token = localStorage.getItem("token"); - if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { showToast("Removed from collection", "success"); @@ -318,15 +319,10 @@ async function doRemoveBook(id: string): Promise { } async function doBulkRemove(ids: string[]): Promise { - const token = localStorage.getItem("token"); - if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers: { ...authHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ book_ids: ids }), }); if (response.ok) { @@ -426,16 +422,18 @@ Alpine.data("collections", () => ({ // setChainOrder — the detail header's listening-order select (plan // §10 R2: the collection's chain order is curated HERE, not on the // player). Merges into view_settings server-side, then reloads so - // the chain producer's stored order takes effect. + // the chain producer's stored order takes effect. The save rides + // the token COOKIE (middleware cookie:token lookup) — never gate on + // localStorage: the 401 interceptor clears it, and a token-gated + // early-return would strand the setting (same class as the + // changeTheme fix, 2026-10-31). setChainOrder(this: any, collectionID: string, order: string) { const token = localStorage.getItem("token"); - if (!token) return; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = "Bearer " + token; void fetch("/api/collections/" + collectionID + "/view-settings", { method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ chain_order: order }), }).then((r) => { if (r.ok) { @@ -496,15 +494,10 @@ async function moveCollectionBook(this: any, mediaId: string, direction: number) .map((c) => c.dataset.mediaId) .filter((id): id is string => Boolean(id)); - const token = localStorage.getItem("token"); - if (!token) return; try { const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, { method: "PUT", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, + headers: { ...authHeaders(), "Content-Type": "application/json" }, body: JSON.stringify({ item_ids: orderedIds }), }); if (!response.ok) { diff --git a/web/src/conflicts.ts b/web/src/conflicts.ts index 12a2e217..7509d8be 100644 --- a/web/src/conflicts.ts +++ b/web/src/conflicts.ts @@ -1,4 +1,5 @@ import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; import { showToast } from "./toast"; async function refreshConflicts(): Promise { @@ -85,14 +86,12 @@ async function resolveConflict( winner: string, manualData?: Record, ): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch(`/api/conflicts/${conflictId}/resolve`, { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ winner, manual_data: manualData }), @@ -115,14 +114,12 @@ async function bulkResolve( strategy: "most_recent" | "highest_progress", conflictIds: string[], ): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch("/api/conflicts/bulk-resolve", { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ conflict_ids: conflictIds, strategy }), @@ -140,14 +137,12 @@ async function bulkResolve( } async function bulkDismiss(conflictIds: string[]): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch("/api/conflicts/bulk-dismiss", { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ conflict_ids: conflictIds }), @@ -164,13 +159,12 @@ async function bulkDismiss(conflictIds: string[]): Promise { } async function dismissAllResolved(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch("/api/conflicts/dismiss-resolved", { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { diff --git a/web/src/custom-section-builder.ts b/web/src/custom-section-builder.ts index 433db06a..faec7a78 100644 --- a/web/src/custom-section-builder.ts +++ b/web/src/custom-section-builder.ts @@ -1,3 +1,4 @@ +import { authHeaders } from "./storage"; import { apiPost } from "./api"; import { showToast } from "./toast"; @@ -377,7 +378,7 @@ async function searchBooks(): Promise { `/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, { headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, + ...authHeaders(), "Content-Type": "application/json", }, }, diff --git a/web/src/dashboard.ts b/web/src/dashboard.ts index 54687b41..6e210bca 100644 --- a/web/src/dashboard.ts +++ b/web/src/dashboard.ts @@ -1,4 +1,5 @@ import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; import { apiPost, apiPut } from "./api"; import { showToast } from "./toast"; import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; @@ -22,7 +23,7 @@ async function openDashboardSettings(): Promise { // scope — the server returns that row when library_id is absent. const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; const response = await fetch(`/api/dashboard/preferences${query}`, { - headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }, + headers: authHeaders(), }); if (response.ok) { @@ -89,7 +90,7 @@ async function fetchAndRenderSections(libraryId: string): Promise { const param = libraryId ? `library_id=${libraryId}` : ""; const response = await fetch(`/api/dashboard/sections?${param}`, { headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, + ...authHeaders(), "Content-Type": "application/json", }, }); @@ -535,7 +536,7 @@ function initDashboard() { const param = libraryId ? `library_id=${libraryId}` : ""; const response = await fetch(`/api/dashboard/sections?${param}`, { headers: { - Authorization: `Bearer ${localStorage.getItem("token")}`, + ...authHeaders(), "Content-Type": "application/json", }, }); diff --git a/web/src/device-management.ts b/web/src/device-management.ts index 66d000f4..cd98e04a 100644 --- a/web/src/device-management.ts +++ b/web/src/device-management.ts @@ -1,6 +1,6 @@ import { Alpine } from "./alpine"; import { showToast } from "./toast"; -import { getToken } from "./storage"; +import { authHeaders, getToken } from "./storage"; function getDeviceIcon(typeName: string): string { const deviceIcons: Record = { @@ -27,8 +27,7 @@ function hideAddDeviceModal(): void { async function handleAddDevice(event: Event): Promise { event.preventDefault(); - const token = getToken(); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it const deviceNameInput = document.getElementById("device-name") as HTMLInputElement; const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement; @@ -47,7 +46,6 @@ async function handleAddDevice(event: Event): Promise { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }); @@ -78,13 +76,12 @@ function showDeviceSettings(deviceId: string): void { deviceIdInput.value = deviceId; - const token = getToken(); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it let deviceType: any; fetch(`/api/devices/${deviceId}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }) .then((res) => res.json()) .then((device) => { @@ -97,7 +94,7 @@ function showDeviceSettings(deviceId: string): void { deviceType = device.device_type; return fetch("/api/collections", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); }) .then((res) => res.json()) @@ -153,7 +150,7 @@ async function loadShelfMappings(deviceId: string): Promise { try { const response = await fetch(`/api/devices/${deviceId}/collections`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); const result = await response.json(); @@ -233,7 +230,7 @@ async function loadCollections(): Promise { try { const response = await fetch("/api/collections", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); const result = await response.json(); @@ -312,7 +309,6 @@ async function handleSaveMapping(event: Event): Promise { method: method, headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }); @@ -342,7 +338,7 @@ async function deleteMapping(mappingId: string): Promise { try { const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { @@ -412,14 +408,13 @@ async function handleSaveDeviceSettings(event: Event): Promise { method: "PUT", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify(deviceData), }); const collectionsRes = await fetch("/api/collections", { method: "GET", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); const collections = await collectionsRes.json(); @@ -431,8 +426,7 @@ async function handleSaveDeviceSettings(event: Event): Promise { method: "PUT", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + }, body: JSON.stringify({ ...collection, view_settings: currentSettings, @@ -473,7 +467,7 @@ async function handleRevokeDevice(): Promise { try { const response = await fetch(`/api/devices/${deviceId}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { @@ -490,13 +484,12 @@ async function handleRevokeDevice(): Promise { } async function approveDevice(registrationId: string): Promise { - const token = getToken(); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch(`/api/devices/approve/${registrationId}`, { method: "GET", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { @@ -512,13 +505,12 @@ async function approveDevice(registrationId: string): Promise { } async function rejectDevice(registrationId: string): Promise { - const token = getToken(); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch(`/api/devices/reject/${registrationId}`, { method: "POST", - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { diff --git a/web/src/filter_builder.ts b/web/src/filter_builder.ts index 00251d23..f4f61200 100644 --- a/web/src/filter_builder.ts +++ b/web/src/filter_builder.ts @@ -12,6 +12,7 @@ // wins server-side. import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; // ─── operator + field metadata (client mirror of // internal/services/filter_registry.go — labels drive the menus) ──── @@ -720,8 +721,7 @@ function filterBuilderData() { // other rules (the server answers the blank query with the // un-typed top list). if (typed.length === 1) return; - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it // Open the menu NOW (loading state) — never a blank flash. this.suggestions[rowKey] = []; this.suggestionsLoading = rowKey; @@ -741,7 +741,7 @@ function filterBuilderData() { params.set("limit", typed ? "8" : "15"); try { const response = await fetch(`/api/media-items/search?${params.toString()}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!response.ok) { console.warn(`suggestion fetch failed (${response.status})`); @@ -792,8 +792,7 @@ function filterBuilderData() { }, 350); }, async fetchMatchCount(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it // Incomplete rules are NOT filters yet — the count runs on the // completed tree only (an incomplete rule would be a 400). const completed = this.completedTree(null); @@ -810,7 +809,7 @@ function filterBuilderData() { this.matchCountLoading = true; try { const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!response.ok) { this.matchCount = null; diff --git a/web/src/header.ts b/web/src/header.ts index e0132511..74936d87 100644 --- a/web/src/header.ts +++ b/web/src/header.ts @@ -12,7 +12,7 @@ import { revealCurrentTheme, updateWoodPanelingIndicators, } from "./theme"; -import { getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage"; +import { authHeaders, getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage"; import { addListener, removeListener } from "./websocket"; // Real sign-out: the HttpOnly session cookie can only be cleared by a @@ -70,10 +70,8 @@ const initializeScanListener = function (this: any) { // 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}` }, + headers: authHeaders(), }); if (!resp.ok) return; const data = await resp.json(); diff --git a/web/src/linking.ts b/web/src/linking.ts index eb78c2ae..56fca4a9 100644 --- a/web/src/linking.ts +++ b/web/src/linking.ts @@ -1,13 +1,13 @@ import { Alpine } from "./alpine"; +import { authHeaders } from "./storage"; import { showToast } from "./toast"; async function loadUnlinkedBooks(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch("/api/sync/unlinked-books", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { @@ -74,14 +74,12 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void { } async function linkBook(progressId: string, mediaItemId: string): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch("/api/sync/link-book", { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -104,8 +102,7 @@ async function linkBook(progressId: string, mediaItemId: string): Promise } async function autoLinkBooks(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it if (!confirm("Auto-link all books with high confidence matches?")) return; @@ -113,7 +110,6 @@ async function autoLinkBooks(): Promise { const response = await fetch("/api/sync/auto-link", { method: "POST", headers: { - Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ confidence_threshold: 0.9 }), @@ -131,12 +127,11 @@ async function autoLinkBooks(): Promise { } async function getSuggestions(progressId: string): Promise { - const token = localStorage.getItem("token"); - if (!token) return; + // cookie auth carries the call; a null bearer must not strand it try { const response = await fetch(`/api/sync/suggestions/${progressId}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (response.ok) { diff --git a/web/src/profile.ts b/web/src/profile.ts index 301f324f..05fb790c 100644 --- a/web/src/profile.ts +++ b/web/src/profile.ts @@ -1,5 +1,5 @@ import { Alpine } from "./alpine"; -import { removeToken } from "./storage"; +import { authHeaders, removeToken } from "./storage"; import { showToast } from "./toast"; async function confirmDeleteAccount(): Promise { @@ -11,18 +11,14 @@ async function confirmDeleteAccount(): Promise { return; } - const token = localStorage.getItem("token"); - if (!token) { - window.location.href = "/login"; - return; - } + // This page is SSR-gated — reaching this script means authed (the + // middleware would have redirected). A localStorage-token check + // wrongly bounced cookie-authed users to /login (2026-10-31). try { const response = await fetch("/api/auth/profile", { method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - }, + headers: authHeaders(), }); if (response.ok) { diff --git a/web/src/stats.ts b/web/src/stats.ts index c26de780..f52a6e5c 100644 --- a/web/src/stats.ts +++ b/web/src/stats.ts @@ -1,3 +1,4 @@ +import { authHeaders } from "./storage"; import { Alpine } from "./alpine"; import { showToast } from "./toast"; import type { ChartConfiguration } from "chart.js"; @@ -213,8 +214,6 @@ function syncUrl(): void { // Summary fetch (Reading tab) ----------------------------------------------- async function loadSummary(): Promise { - const token = localStorage.getItem("token"); - if (!token) return; document.getElementById("stats-empty")?.classList.add("hidden"); const windowLabel = document.getElementById("stats-window-label"); @@ -222,7 +221,7 @@ async function loadSummary(): Promise { try { const res = await fetch(`/api/stats/summary?range=${encodeURIComponent(currentRange)}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (res.status === 400) { const err = await res.json(); @@ -527,10 +526,8 @@ function renderTopBooks(books: TopBook[]): void { // Library tab --------------------------------------------------------------- async function loadLibraries(): Promise { - const token = localStorage.getItem("token"); - if (!token) return []; const res = await fetch("/api/libraries/visibility", { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (!res.ok) { showToast("Failed to load libraries", "error"); @@ -554,8 +551,6 @@ function fillLibrarySelector(rows: VisibleLibrary[]): void { async function loadLibraryUsage(): Promise { if (libraryDataLoaded) return; - const token = localStorage.getItem("token"); - if (!token) return; libraryDataLoaded = true; const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : ""; @@ -580,7 +575,7 @@ async function loadLibraryUsage(): Promise { try { const res = await fetch(`/api/stats/library-usage${qs}`, { - headers: { Authorization: `Bearer ${token}` }, + headers: authHeaders(), }); if (res.status === 403) { showToast("Library not visible to you", "error"); diff --git a/web/src/storage.ts b/web/src/storage.ts index 5ae0e573..78c1c7b6 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -39,6 +39,18 @@ function getSelectedLibrary(): string { return ""; } +// Headers for same-origin /api fetches: the localStorage bearer when +// present, otherwise nothing — the token COOKIE (always sent on +// same-origin requests) carries auth, since the middleware's +// TokenLookup checks cookie:token before the header. NEVER gate a +// call on the bearer's presence: the global 401 interceptor clears +// localStorage, and token-gated calls then strand every click that +// should persist ("can no longer change themes", 2026-10-31). +function authHeaders(): Record { + const token = localStorage.getItem("token"); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + function setSelectedLibrary(libraryId: string): void { const value = libraryId === "" ? ALL_LIBRARIES : libraryId; localStorage.setItem("selectedLibrary", value); @@ -51,6 +63,7 @@ function clearAll(): void { export { ALL_LIBRARIES, + authHeaders, clearAll, getRefreshToken, getSelectedLibrary, diff --git a/web/src/tag-dropdown.ts b/web/src/tag-dropdown.ts index 67910817..7876cc1d 100644 --- a/web/src/tag-dropdown.ts +++ b/web/src/tag-dropdown.ts @@ -1,3 +1,5 @@ +import { authHeaders } from "./storage"; + export interface TagSuggestion { value: string; count: number; @@ -9,13 +11,11 @@ export async function searchTagSuggestions( ): Promise { if (!query || query.length < 2 || !libraryId) return []; - const token = localStorage.getItem("token"); - if (!token) return []; try { const response = await fetch( `/api/media-items/search?tags=${encodeURIComponent(query)}&library_id=${libraryId}&limit=20`, - { headers: { Authorization: `Bearer ${token}` } }, + { headers: authHeaders() }, ); if (!response.ok) return []; diff --git a/web/src/theme.ts b/web/src/theme.ts index d0b39c27..1d541dd1 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -91,25 +91,28 @@ const changeTheme = async (theme: string): Promise => { applyTheme(theme); updateThemeIndicators(); - // Save to server if logged in + // Save to server. The /api middleware accepts cookie:token OR the + // bearer header — the global fetch interceptor wipes localStorage on + // any 401, which previously made a token-gated early-return strand + // theme persistence forever (reloads re-rendered the old server + // theme — "can no longer change themes", 2026-10-31). The cookie + // carries the PUT on its own; the bearer is only a bonus header. const token = localStorage.getItem(TOKEN_STORAGE_KEY); - if (!token) return; + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers.Authorization = `Bearer ${token}`; try { const response = await fetch("/api/auth/theme", { method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ theme }), }); if (!response.ok) { - console.log("Theme save failed"); + console.warn("Theme save failed:", response.status); } } catch (error) { - console.log("Theme save failed", error); + console.warn("Theme save failed", error); } };