fix(ui): cookie-authed users' writes silently stranded behind bearer gates

THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.

Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.

Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.

Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
This commit is contained in:
John O'Keefe committed 2026-10-31 17:02:44 -04:00
1 parent 744d33e13f
commit aaf57a66a2
18 files changed
+138 -188

No files matched your search

+25
View File
@@ -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 page.locator('.theme-btn[data-theme="tokyo-night"]').click();
await back; await back;
expectNoNewConsoleErrors(); 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 }) => { test("pencil glyph vs card body: modal stays, card navigates", async ({ page }) => {
+14 -47
View File
@@ -1,5 +1,6 @@
import { confirmDialog } from "./confirm"; import { confirmDialog } from "./confirm";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { showToast } from "./toast"; import { showToast } from "./toast";
// Admin API calls authenticate with the Bearer token stashed at login, // 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- // web session can still have an empty/stale localStorage token. User-
// triggered actions must never die silently in that state (the // triggered actions must never die silently in that state (the
// /admin/archived purge + delete buttons were invisible dead-ends: 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<void> { async function scanAllLibraries(): Promise<void> {
const token = requireToken();
if (!token) return;
try { try {
const libsResp = await fetch("/api/libraries", { const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!libsResp.ok) { if (!libsResp.ok) {
@@ -46,7 +35,7 @@ async function scanAllLibraries(): Promise<void> {
const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, { const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ force: true }), body: JSON.stringify({ force: true }),
@@ -125,7 +114,6 @@ function showScanProgress(jobIds: string[], libraryNames: Record<string, string>
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined; let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void { function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string>): void {
const token = localStorage.getItem("token");
const startTime = Date.now(); const startTime = Date.now();
const cancelledJobs = new Set<string>(); const cancelledJobs = new Set<string>();
@@ -140,7 +128,7 @@ function pollScanProgress(jobIds: string[], _libraryNames: Record<string, string
for (const jobId of jobIds) { for (const jobId of jobIds) {
try { try {
const resp = await fetch(`/api/scanner/status/${jobId}`, { const resp = await fetch(`/api/scanner/status/${jobId}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (resp.ok) { if (resp.ok) {
@@ -241,8 +229,6 @@ function updateLibraryProgress(jobId: string, status: any): void {
// aborts the walk at its next context check and skips the archive pass, // aborts the walk at its next context check and skips the archive pass,
// so cancelling never archives unvisited files. // so cancelling never archives unvisited files.
async function cancelScan(jobId: string): Promise<void> { async function cancelScan(jobId: string): Promise<void> {
const token = requireToken();
if (!token) return;
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`); const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
if (btn) { if (btn) {
@@ -253,7 +239,7 @@ async function cancelScan(jobId: string): Promise<void> {
try { try {
const resp = await fetch(`/api/scanner/cancel/${jobId}`, { const resp = await fetch(`/api/scanner/cancel/${jobId}`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
@@ -273,12 +259,10 @@ async function cancelScan(jobId: string): Promise<void> {
// list so it also covers jobs this page never started (e.g. started // list so it also covers jobs this page never started (e.g. started
// before a refresh), then cancels each one. // before a refresh), then cancels each one.
async function cancelAllScans(): Promise<void> { async function cancelAllScans(): Promise<void> {
const token = requireToken();
if (!token) return;
try { try {
const resp = await fetch("/api/scanner/active", { const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) { if (!resp.ok) {
throw new Error("Failed to list active scans"); 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 // widget and re-attach polling. Websocket start-events only reach pages
// that were already open when the scan began. // that were already open when the scan began.
async function checkActiveScans(): Promise<void> { async function checkActiveScans(): Promise<void> {
// Passive page-load probe, not a user action: stay silent when the API // Passive page-load probe, not a user action — cookie auth carries it.
// token is absent (requireToken's toast would fire on every page visit).
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const resp = await fetch("/api/scanner/active", { const resp = await fetch("/api/scanner/active", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
@@ -323,7 +304,7 @@ async function checkActiveScans(): Promise<void> {
const libraryNames: Record<string, string> = {}; const libraryNames: Record<string, string> = {};
try { try {
const libsResp = await fetch("/api/libraries", { const libsResp = await fetch("/api/libraries", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (libsResp.ok) { if (libsResp.ok) {
const libsData = await libsResp.json(); const libsData = await libsResp.json();
@@ -389,13 +370,10 @@ function hideScanProgress(): void {
async function loadWatchStatus(): Promise<void> { async function loadWatchStatus(): Promise<void> {
// Passive status fetch inside the admin component's init, not a user // Passive status fetch inside the admin component's init, not a user
// action: stay silent when the API token is absent (requireToken's // 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 { try {
const response = await fetch("/api/scanner/watch/status", { const response = await fetch("/api/scanner/watch/status", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@@ -420,8 +398,6 @@ function stopScanStatusPolling(): void {
// window for the library admin page's inline button; reading history is // window for the library admin page's inline button; reading history is
// deleted with the rows, so a confirm dialog guards it. // deleted with the rows, so a confirm dialog guards it.
async function purgeArchivedItems(): Promise<void> { async function purgeArchivedItems(): Promise<void> {
const token = requireToken();
if (!token) return;
if ( if (
!(await confirmDialog( !(await confirmDialog(
@@ -434,7 +410,7 @@ async function purgeArchivedItems(): Promise<void> {
try { try {
const resp = await fetch("/api/media-items/purge-archived", { const resp = await fetch("/api/media-items/purge-archived", {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
@@ -452,12 +428,10 @@ async function purgeArchivedItems(): Promise<void> {
// its file to return (POST /api/media-items/:id/unarchive). If the file is // its file to return (POST /api/media-items/:id/unarchive). If the file is
// still gone the next scan hides it again. // still gone the next scan hides it again.
async function unarchiveItem(id: string): Promise<void> { async function unarchiveItem(id: string): Promise<void> {
const token = requireToken();
if (!token) return;
try { try {
const resp = await fetch(`/api/media-items/${id}/unarchive`, { const resp = await fetch(`/api/media-items/${id}/unarchive`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
@@ -473,8 +447,6 @@ async function unarchiveItem(id: string): Promise<void> {
// Permanently delete one archived item and its reading history // Permanently delete one archived item and its reading history
// (DELETE /api/media-items/:id). // (DELETE /api/media-items/:id).
async function deleteArchivedItem(id: string): Promise<void> { async function deleteArchivedItem(id: string): Promise<void> {
const token = requireToken();
if (!token) return;
if ( if (
!(await confirmDialog( !(await confirmDialog(
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.", "Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
@@ -485,7 +457,7 @@ async function deleteArchivedItem(id: string): Promise<void> {
try { try {
const resp = await fetch(`/api/media-items/${id}`, { const resp = await fetch(`/api/media-items/${id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
@@ -502,8 +474,6 @@ async function deleteArchivedItem(id: string): Promise<void> {
// same content hash (POST /api/media-items/:id/merge), then remove the // same content hash (POST /api/media-items/:id/merge), then remove the
// archived entry. Covers copy-then-delete-later moves between libraries. // archived entry. Covers copy-then-delete-later moves between libraries.
async function mergeArchivedItem(sourceId: string, targetId: string): Promise<void> { async function mergeArchivedItem(sourceId: string, targetId: string): Promise<void> {
const token = requireToken();
if (!token) return;
if ( if (
!(await confirmDialog( !(await confirmDialog(
"Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.", "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<vo
try { try {
const resp = await fetch(`/api/media-items/${sourceId}/merge`, { const resp = await fetch(`/api/media-items/${sourceId}/merge`, {
method: "POST", method: "POST",
headers: { headers: { ...authHeaders(), "Content-Type": "application/json" },
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ target_id: targetId }), body: JSON.stringify({ target_id: targetId }),
}); });
if (!resp.ok) { if (!resp.ok) {
+2 -6
View File
@@ -1,6 +1,7 @@
// Book Picker for Collections - multi-select book picker modal // Book Picker for Collections - multi-select book picker modal
// CRITICAL: Selection state must persist across HTMX grid updates // CRITICAL: Selection state must persist across HTMX grid updates
import { authHeaders } from "./storage";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
@@ -80,11 +81,6 @@ Alpine.store("bookPicker", {
}, },
async submit() { async submit() {
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
if (this.selectedBooks.size === 0) { if (this.selectedBooks.size === 0) {
showToast("No books selected", "error"); showToast("No books selected", "error");
@@ -99,8 +95,8 @@ Alpine.store("bookPicker", {
const response = await fetch(`/api/collections/${collectionId}/books`, { const response = await fetch(`/api/collections/${collectionId}/books`, {
method: "POST", method: "POST",
headers: { headers: {
...authHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
book_ids: Array.from(this.selectedBooks), book_ids: Array.from(this.selectedBooks),
+4 -18
View File
@@ -1,5 +1,6 @@
// Bookshelf functionality - procedural/imperative style // Bookshelf functionality - procedural/imperative style
import { authHeaders } from "./storage";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import type { FilterGroupState, FilterRuleState } from "./filter_builder"; import type { FilterGroupState, FilterRuleState } from "./filter_builder";
@@ -183,11 +184,6 @@ Alpine.data("bookshelf", () => ({
// The qf engine then drives it everywhere — shelf, collections page, // The qf engine then drives it everywhere — shelf, collections page,
// play chains — with zero re-authoring. // play chains — with zero re-authoring.
async saveFilterAsCollection() { 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 nameInput = document.getElementById("save-filter-name") as HTMLInputElement | null;
const name = nameInput?.value?.trim() ?? ""; const name = nameInput?.value?.trim() ?? "";
if (!name) { if (!name) {
@@ -203,7 +199,7 @@ Alpine.data("bookshelf", () => ({
try { try {
const response = await fetch("/api/collections", { const response = await fetch("/api/collections", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, headers: { ...authHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ name, filter_tree: tree }), body: JSON.stringify({ name, filter_tree: tree }),
}); });
if (response.ok) { if (response.ok) {
@@ -231,16 +227,11 @@ Alpine.data("bookshelf", () => ({
showToast(`Loading filter: ${filterName}...`, "info"); showToast(`Loading filter: ${filterName}...`, "info");
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
try { try {
// Fetch filter details from API // Fetch filter details from API
const response = await fetch(`/api/saved-filters/${filterId}`, { const response = await fetch(`/api/saved-filters/${filterId}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!response.ok) { if (!response.ok) {
@@ -320,16 +311,11 @@ Alpine.data("bookshelf", () => ({
if (!confirm("Are you sure you want to delete this filter?")) return; 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 { try {
const response = await fetch(`/api/saved-filters/${filterId}`, { const response = await fetch(`/api/saved-filters/${filterId}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
+2 -6
View File
@@ -8,6 +8,7 @@
// arrays preload as their CONVERTED tree (RulesToFilterTree, rendered // arrays preload as their CONVERTED tree (RulesToFilterTree, rendered
// server-side) and upgrade at the first Apply. // server-side) and upgrade at the first Apply.
import { authHeaders } from "./storage";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
@@ -22,11 +23,6 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi
// absent at init timing. // absent at init timing.
Alpine.store("collectionTreeApply", { Alpine.store("collectionTreeApply", {
hook: async (wire: string) => { hook: async (wire: string) => {
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
const name = pageCollectionName(); const name = pageCollectionName();
if (!name) { if (!name) {
showToast("Cannot determine the collection name", "error"); showToast("Cannot determine the collection name", "error");
@@ -35,7 +31,7 @@ function initCollectionRules(id: string, _scope: string, _treeJSON: string): voi
try { try {
const response = await fetch(`/api/collections/${collectionId}`, { const response = await fetch(`/api/collections/${collectionId}`, {
method: "PUT", 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) }), body: JSON.stringify({ name, filter_tree: JSON.parse(wire) }),
}); });
if (response.ok) { if (response.ok) {
+15 -22
View File
@@ -1,4 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { createWebSocket } from "./websocket"; import { createWebSocket } from "./websocket";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
@@ -50,7 +51,9 @@ function initializeCollectionWebSocket(): void {
function setupHTMXAuth(): void { function setupHTMXAuth(): void {
document.body.addEventListener("htmx:configRequest", function (evt: Event) { document.body.addEventListener("htmx:configRequest", function (evt: Event) {
const token = localStorage.getItem("token"); 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<void> { async function doRemoveBook(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, { const response = await fetch(`/api/collections/${getCollectionId()}/books/${id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
showToast("Removed from collection", "success"); showToast("Removed from collection", "success");
@@ -318,15 +319,10 @@ async function doRemoveBook(id: string): Promise<void> {
} }
async function doBulkRemove(ids: string[]): Promise<void> { async function doBulkRemove(ids: string[]): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, { const response = await fetch(`/api/collections/${getCollectionId()}/books/bulk-remove`, {
method: "POST", method: "POST",
headers: { headers: { ...authHeaders(), "Content-Type": "application/json" },
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ book_ids: ids }), body: JSON.stringify({ book_ids: ids }),
}); });
if (response.ok) { if (response.ok) {
@@ -426,16 +422,18 @@ Alpine.data("collections", () => ({
// setChainOrder — the detail header's listening-order select (plan // setChainOrder — the detail header's listening-order select (plan
// §10 R2: the collection's chain order is curated HERE, not on the // §10 R2: the collection's chain order is curated HERE, not on the
// player). Merges into view_settings server-side, then reloads so // 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) { setChainOrder(this: any, collectionID: string, order: string) {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = "Bearer " + token;
void fetch("/api/collections/" + collectionID + "/view-settings", { void fetch("/api/collections/" + collectionID + "/view-settings", {
method: "PUT", method: "PUT",
headers: { headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ chain_order: order }), body: JSON.stringify({ chain_order: order }),
}).then((r) => { }).then((r) => {
if (r.ok) { if (r.ok) {
@@ -496,15 +494,10 @@ async function moveCollectionBook(this: any, mediaId: string, direction: number)
.map((c) => c.dataset.mediaId) .map((c) => c.dataset.mediaId)
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
const token = localStorage.getItem("token");
if (!token) return;
try { try {
const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, { const response = await fetch(`/api/collections/${getCollectionId()}/items/order`, {
method: "PUT", method: "PUT",
headers: { headers: { ...authHeaders(), "Content-Type": "application/json" },
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ item_ids: orderedIds }), body: JSON.stringify({ item_ids: orderedIds }),
}); });
if (!response.ok) { if (!response.ok) {
+6 -12
View File
@@ -1,4 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function refreshConflicts(): Promise<void> { async function refreshConflicts(): Promise<void> {
@@ -85,14 +86,12 @@ async function resolveConflict(
winner: string, winner: string,
manualData?: Record<string, unknown>, manualData?: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch(`/api/conflicts/${conflictId}/resolve`, { const response = await fetch(`/api/conflicts/${conflictId}/resolve`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ winner, manual_data: manualData }), body: JSON.stringify({ winner, manual_data: manualData }),
@@ -115,14 +114,12 @@ async function bulkResolve(
strategy: "most_recent" | "highest_progress", strategy: "most_recent" | "highest_progress",
conflictIds: string[], conflictIds: string[],
): Promise<void> { ): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch("/api/conflicts/bulk-resolve", { const response = await fetch("/api/conflicts/bulk-resolve", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ conflict_ids: conflictIds, strategy }), body: JSON.stringify({ conflict_ids: conflictIds, strategy }),
@@ -140,14 +137,12 @@ async function bulkResolve(
} }
async function bulkDismiss(conflictIds: string[]): Promise<void> { async function bulkDismiss(conflictIds: string[]): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch("/api/conflicts/bulk-dismiss", { const response = await fetch("/api/conflicts/bulk-dismiss", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ conflict_ids: conflictIds }), body: JSON.stringify({ conflict_ids: conflictIds }),
@@ -164,13 +159,12 @@ async function bulkDismiss(conflictIds: string[]): Promise<void> {
} }
async function dismissAllResolved(): Promise<void> { async function dismissAllResolved(): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch("/api/conflicts/dismiss-resolved", { const response = await fetch("/api/conflicts/dismiss-resolved", {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { authHeaders } from "./storage";
import { apiPost } from "./api"; import { apiPost } from "./api";
import { showToast } from "./toast"; import { showToast } from "./toast";
@@ -377,7 +378,7 @@ async function searchBooks(): Promise<void> {
`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, `/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`,
{ {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`, ...authHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}, },
+4 -3
View File
@@ -1,4 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { apiPost, apiPut } from "./api"; import { apiPost, apiPut } from "./api";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { initLibrarySwitcher, switchWithTransition } from "./library-switcher"; import { initLibrarySwitcher, switchWithTransition } from "./library-switcher";
@@ -22,7 +23,7 @@ async function openDashboardSettings(): Promise<void> {
// scope — the server returns that row when library_id is absent. // scope — the server returns that row when library_id is absent.
const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : ""; const query = libraryId ? `?library_id=${encodeURIComponent(libraryId)}` : "";
const response = await fetch(`/api/dashboard/preferences${query}`, { const response = await fetch(`/api/dashboard/preferences${query}`, {
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
@@ -89,7 +90,7 @@ async function fetchAndRenderSections(libraryId: string): Promise<void> {
const param = libraryId ? `library_id=${libraryId}` : ""; const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch(`/api/dashboard/sections?${param}`, { const response = await fetch(`/api/dashboard/sections?${param}`, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`, ...authHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
@@ -535,7 +536,7 @@ function initDashboard() {
const param = libraryId ? `library_id=${libraryId}` : ""; const param = libraryId ? `library_id=${libraryId}` : "";
const response = await fetch(`/api/dashboard/sections?${param}`, { const response = await fetch(`/api/dashboard/sections?${param}`, {
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`, ...authHeaders(),
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
+14 -22
View File
@@ -1,6 +1,6 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import { getToken } from "./storage"; import { authHeaders, getToken } from "./storage";
function getDeviceIcon(typeName: string): string { function getDeviceIcon(typeName: string): string {
const deviceIcons: Record<string, string> = { const deviceIcons: Record<string, string> = {
@@ -27,8 +27,7 @@ function hideAddDeviceModal(): void {
async function handleAddDevice(event: Event): Promise<void> { async function handleAddDevice(event: Event): Promise<void> {
event.preventDefault(); event.preventDefault();
const token = getToken(); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement; const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement; const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
@@ -47,7 +46,6 @@ async function handleAddDevice(event: Event): Promise<void> {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -78,13 +76,12 @@ function showDeviceSettings(deviceId: string): void {
deviceIdInput.value = deviceId; deviceIdInput.value = deviceId;
const token = getToken(); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
let deviceType: any; let deviceType: any;
fetch(`/api/devices/${deviceId}`, { fetch(`/api/devices/${deviceId}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((device) => { .then((device) => {
@@ -97,7 +94,7 @@ function showDeviceSettings(deviceId: string): void {
deviceType = device.device_type; deviceType = device.device_type;
return fetch("/api/collections", { return fetch("/api/collections", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
}) })
.then((res) => res.json()) .then((res) => res.json())
@@ -153,7 +150,7 @@ async function loadShelfMappings(deviceId: string): Promise<void> {
try { try {
const response = await fetch(`/api/devices/${deviceId}/collections`, { const response = await fetch(`/api/devices/${deviceId}/collections`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
const result = await response.json(); const result = await response.json();
@@ -233,7 +230,7 @@ async function loadCollections(): Promise<void> {
try { try {
const response = await fetch("/api/collections", { const response = await fetch("/api/collections", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
const result = await response.json(); const result = await response.json();
@@ -312,7 +309,6 @@ async function handleSaveMapping(event: Event): Promise<void> {
method: method, method: method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
@@ -342,7 +338,7 @@ async function deleteMapping(mappingId: string): Promise<void> {
try { try {
const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, { const response = await fetch(`/api/devices/${deviceId}/collections/${mappingId}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
@@ -412,14 +408,13 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify(deviceData), body: JSON.stringify(deviceData),
}); });
const collectionsRes = await fetch("/api/collections", { const collectionsRes = await fetch("/api/collections", {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
const collections = await collectionsRes.json(); const collections = await collectionsRes.json();
@@ -431,7 +426,6 @@ async function handleSaveDeviceSettings(event: Event): Promise<void> {
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
...collection, ...collection,
@@ -473,7 +467,7 @@ async function handleRevokeDevice(): Promise<void> {
try { try {
const response = await fetch(`/api/devices/${deviceId}`, { const response = await fetch(`/api/devices/${deviceId}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
@@ -490,13 +484,12 @@ async function handleRevokeDevice(): Promise<void> {
} }
async function approveDevice(registrationId: string): Promise<void> { async function approveDevice(registrationId: string): Promise<void> {
const token = getToken(); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch(`/api/devices/approve/${registrationId}`, { const response = await fetch(`/api/devices/approve/${registrationId}`, {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
@@ -512,13 +505,12 @@ async function approveDevice(registrationId: string): Promise<void> {
} }
async function rejectDevice(registrationId: string): Promise<void> { async function rejectDevice(registrationId: string): Promise<void> {
const token = getToken(); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch(`/api/devices/reject/${registrationId}`, { const response = await fetch(`/api/devices/reject/${registrationId}`, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
+5 -6
View File
@@ -12,6 +12,7 @@
// wins server-side. // wins server-side.
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
// ─── operator + field metadata (client mirror of // ─── operator + field metadata (client mirror of
// internal/services/filter_registry.go — labels drive the menus) ──── // 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 // other rules (the server answers the blank query with the
// un-typed top list). // un-typed top list).
if (typed.length === 1) return; if (typed.length === 1) return;
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
// Open the menu NOW (loading state) — never a blank flash. // Open the menu NOW (loading state) — never a blank flash.
this.suggestions[rowKey] = []; this.suggestions[rowKey] = [];
this.suggestionsLoading = rowKey; this.suggestionsLoading = rowKey;
@@ -741,7 +741,7 @@ function filterBuilderData() {
params.set("limit", typed ? "8" : "15"); params.set("limit", typed ? "8" : "15");
try { try {
const response = await fetch(`/api/media-items/search?${params.toString()}`, { const response = await fetch(`/api/media-items/search?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!response.ok) { if (!response.ok) {
console.warn(`suggestion fetch failed (${response.status})`); console.warn(`suggestion fetch failed (${response.status})`);
@@ -792,8 +792,7 @@ function filterBuilderData() {
}, 350); }, 350);
}, },
async fetchMatchCount(): Promise<void> { async fetchMatchCount(): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
// Incomplete rules are NOT filters yet — the count runs on the // Incomplete rules are NOT filters yet — the count runs on the
// completed tree only (an incomplete rule would be a 400). // completed tree only (an incomplete rule would be a 400).
const completed = this.completedTree(null); const completed = this.completedTree(null);
@@ -810,7 +809,7 @@ function filterBuilderData() {
this.matchCountLoading = true; this.matchCountLoading = true;
try { try {
const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, { const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!response.ok) { if (!response.ok) {
this.matchCount = null; this.matchCount = null;
+2 -4
View File
@@ -12,7 +12,7 @@ import {
revealCurrentTheme, revealCurrentTheme,
updateWoodPanelingIndicators, updateWoodPanelingIndicators,
} from "./theme"; } from "./theme";
import { getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage"; import { authHeaders, getSelectedLibrary, getRefreshToken, removeRefreshToken } from "./storage";
import { addListener, removeListener } from "./websocket"; import { addListener, removeListener } from "./websocket";
// Real sign-out: the HttpOnly session cookie can only be cleared by a // 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. // its next progress tick, so ask the server directly first.
const restoreActiveScan = async () => { const restoreActiveScan = async () => {
try { try {
const token = localStorage.getItem("token");
if (!token) return;
const resp = await fetch("/api/scanner/active-summary", { const resp = await fetch("/api/scanner/active-summary", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!resp.ok) return; if (!resp.ok) return;
const data = await resp.json(); const data = await resp.json();
+7 -12
View File
@@ -1,13 +1,13 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { authHeaders } from "./storage";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function loadUnlinkedBooks(): Promise<void> { async function loadUnlinkedBooks(): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch("/api/sync/unlinked-books", { const response = await fetch("/api/sync/unlinked-books", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
@@ -74,14 +74,12 @@ function renderUnlinkedBooks(books: UnlinkedBookData[]): void {
} }
async function linkBook(progressId: string, mediaItemId: string): Promise<void> { async function linkBook(progressId: string, mediaItemId: string): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch("/api/sync/link-book", { const response = await fetch("/api/sync/link-book", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -104,8 +102,7 @@ async function linkBook(progressId: string, mediaItemId: string): Promise<void>
} }
async function autoLinkBooks(): Promise<void> { async function autoLinkBooks(): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
if (!confirm("Auto-link all books with high confidence matches?")) return; if (!confirm("Auto-link all books with high confidence matches?")) return;
@@ -113,7 +110,6 @@ async function autoLinkBooks(): Promise<void> {
const response = await fetch("/api/sync/auto-link", { const response = await fetch("/api/sync/auto-link", {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ confidence_threshold: 0.9 }), body: JSON.stringify({ confidence_threshold: 0.9 }),
@@ -131,12 +127,11 @@ async function autoLinkBooks(): Promise<void> {
} }
async function getSuggestions(progressId: string): Promise<void> { async function getSuggestions(progressId: string): Promise<void> {
const token = localStorage.getItem("token"); // cookie auth carries the call; a null bearer must not strand it
if (!token) return;
try { try {
const response = await fetch(`/api/sync/suggestions/${progressId}`, { const response = await fetch(`/api/sync/suggestions/${progressId}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (response.ok) { if (response.ok) {
+5 -9
View File
@@ -1,5 +1,5 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { removeToken } from "./storage"; import { authHeaders, removeToken } from "./storage";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function confirmDeleteAccount(): Promise<void> { async function confirmDeleteAccount(): Promise<void> {
@@ -11,18 +11,14 @@ async function confirmDeleteAccount(): Promise<void> {
return; return;
} }
const token = localStorage.getItem("token"); // This page is SSR-gated — reaching this script means authed (the
if (!token) { // middleware would have redirected). A localStorage-token check
window.location.href = "/login"; // wrongly bounced cookie-authed users to /login (2026-10-31).
return;
}
try { try {
const response = await fetch("/api/auth/profile", { const response = await fetch("/api/auth/profile", {
method: "DELETE", method: "DELETE",
headers: { headers: authHeaders(),
Authorization: `Bearer ${token}`,
},
}); });
if (response.ok) { if (response.ok) {
+4 -9
View File
@@ -1,3 +1,4 @@
import { authHeaders } from "./storage";
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
import type { ChartConfiguration } from "chart.js"; import type { ChartConfiguration } from "chart.js";
@@ -213,8 +214,6 @@ function syncUrl(): void {
// Summary fetch (Reading tab) ----------------------------------------------- // Summary fetch (Reading tab) -----------------------------------------------
async function loadSummary(): Promise<void> { async function loadSummary(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
document.getElementById("stats-empty")?.classList.add("hidden"); document.getElementById("stats-empty")?.classList.add("hidden");
const windowLabel = document.getElementById("stats-window-label"); const windowLabel = document.getElementById("stats-window-label");
@@ -222,7 +221,7 @@ async function loadSummary(): Promise<void> {
try { try {
const res = await fetch(`/api/stats/summary?range=${encodeURIComponent(currentRange)}`, { const res = await fetch(`/api/stats/summary?range=${encodeURIComponent(currentRange)}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (res.status === 400) { if (res.status === 400) {
const err = await res.json(); const err = await res.json();
@@ -527,10 +526,8 @@ function renderTopBooks(books: TopBook[]): void {
// Library tab --------------------------------------------------------------- // Library tab ---------------------------------------------------------------
async function loadLibraries(): Promise<VisibleLibrary[]> { async function loadLibraries(): Promise<VisibleLibrary[]> {
const token = localStorage.getItem("token");
if (!token) return [];
const res = await fetch("/api/libraries/visibility", { const res = await fetch("/api/libraries/visibility", {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (!res.ok) { if (!res.ok) {
showToast("Failed to load libraries", "error"); showToast("Failed to load libraries", "error");
@@ -554,8 +551,6 @@ function fillLibrarySelector(rows: VisibleLibrary[]): void {
async function loadLibraryUsage(): Promise<void> { async function loadLibraryUsage(): Promise<void> {
if (libraryDataLoaded) return; if (libraryDataLoaded) return;
const token = localStorage.getItem("token");
if (!token) return;
libraryDataLoaded = true; libraryDataLoaded = true;
const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : ""; const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : "";
@@ -580,7 +575,7 @@ async function loadLibraryUsage(): Promise<void> {
try { try {
const res = await fetch(`/api/stats/library-usage${qs}`, { const res = await fetch(`/api/stats/library-usage${qs}`, {
headers: { Authorization: `Bearer ${token}` }, headers: authHeaders(),
}); });
if (res.status === 403) { if (res.status === 403) {
showToast("Library not visible to you", "error"); showToast("Library not visible to you", "error");
+13
View File
@@ -39,6 +39,18 @@ function getSelectedLibrary(): string {
return ""; 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<string, string> {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
function setSelectedLibrary(libraryId: string): void { function setSelectedLibrary(libraryId: string): void {
const value = libraryId === "" ? ALL_LIBRARIES : libraryId; const value = libraryId === "" ? ALL_LIBRARIES : libraryId;
localStorage.setItem("selectedLibrary", value); localStorage.setItem("selectedLibrary", value);
@@ -51,6 +63,7 @@ function clearAll(): void {
export { export {
ALL_LIBRARIES, ALL_LIBRARIES,
authHeaders,
clearAll, clearAll,
getRefreshToken, getRefreshToken,
getSelectedLibrary, getSelectedLibrary,
+3 -3
View File
@@ -1,3 +1,5 @@
import { authHeaders } from "./storage";
export interface TagSuggestion { export interface TagSuggestion {
value: string; value: string;
count: number; count: number;
@@ -9,13 +11,11 @@ export async function searchTagSuggestions(
): Promise<TagSuggestion[]> { ): Promise<TagSuggestion[]> {
if (!query || query.length < 2 || !libraryId) return []; if (!query || query.length < 2 || !libraryId) return [];
const token = localStorage.getItem("token");
if (!token) return [];
try { try {
const response = await fetch( const response = await fetch(
`/api/media-items/search?tags=${encodeURIComponent(query)}&library_id=${libraryId}&limit=20`, `/api/media-items/search?tags=${encodeURIComponent(query)}&library_id=${libraryId}&limit=20`,
{ headers: { Authorization: `Bearer ${token}` } }, { headers: authHeaders() },
); );
if (!response.ok) return []; if (!response.ok) return [];
+11 -8
View File
@@ -91,25 +91,28 @@ const changeTheme = async (theme: string): Promise<void> => {
applyTheme(theme); applyTheme(theme);
updateThemeIndicators(); 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); const token = localStorage.getItem(TOKEN_STORAGE_KEY);
if (!token) return; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
try { try {
const response = await fetch("/api/auth/theme", { const response = await fetch("/api/auth/theme", {
method: "PUT", method: "PUT",
headers: { headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }), body: JSON.stringify({ theme }),
}); });
if (!response.ok) { if (!response.ok) {
console.log("Theme save failed"); console.warn("Theme save failed:", response.status);
} }
} catch (error) { } catch (error) {
console.log("Theme save failed", error); console.warn("Theme save failed", error);
} }
}; };