From f1e4b2a603d6d35386ce3bc4c2784b1b42f1d154 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 28 Oct 2026 09:37:14 -0400 Subject: [PATCH] fix(admin): un-dead the archived page's purge and delete buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent dead-ends, confirmed on prod's /admin/archived (2026-10): Purge All rendered but clicking it did nothing at all, and per-row Delete never even showed its confirm dialog. Purge All is Alpine-bound (@click="purgeArchivedItems()"), but this page's lacked x-data="admin" — the only admin page without it. Alpine evaluates directives only inside an x-data tree, so the attribute was inert: the admin component (and its method, registered in admin.ts) never instantiated here. The body now carries x-data="admin", matching admin.templ and admin_library.templ. Per-item Delete was a second, subtler dead-end: admin.ts's API helpers guard with `if (!token) return;` BEFORE the confirm dialog, and the reporting session had no localStorage token — SSR pages run on the cookie session, so the web UI works while every Bearer- authenticated admin action (purge, delete, restore, merge, scan-all, cancel-scan) silently no-ops with zero feedback. Those guards now go through requireToken, which toasts "Session expired — sign out and back in, then retry" when the token is absent. The two passive page-load probes (loadWatchStatus, checkActiveScans) deliberately keep the silent return: a toast there would fire on every visit. Pinned by TestAdminArchivedPageHasAlpineScope: renders the page and asserts the body scope and the action buttons coexist in one render. After deploy: hard-refresh prod, click Purge All (confirm appears); if the token toast fires on any admin action, log out and back in once to stash a fresh API token. --- templates/admin_archived.templ | 2 +- templates/admin_archived_render_test.go | 47 +++++++++++++++++++++++++ templates/admin_archived_templ.go | 2 +- web/src/admin.ts | 34 ++++++++++++++---- 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 templates/admin_archived_render_test.go diff --git a/templates/admin_archived.templ b/templates/admin_archived.templ index 9ed58623..8c67c1ed 100644 --- a/templates/admin_archived.templ +++ b/templates/admin_archived.templ @@ -10,7 +10,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) { - + @Header(user, "/admin/archived")
diff --git a/templates/admin_archived_render_test.go b/templates/admin_archived_render_test.go new file mode 100644 index 00000000..ed9d77e4 --- /dev/null +++ b/templates/admin_archived_render_test.go @@ -0,0 +1,47 @@ +package templates + +import ( + "context" + "strings" + "testing" + "time" +) + +// The archived-items admin page binds its buttons through the page-wide +// Alpine component: Purge All is @click="purgeArchivedItems()" and the +// per-row Delete/Restore/Merge buttons rely on listeners main.ts binds at +// module load. The body tag therefore MUST carry x-data="admin" — the +// other admin pages have it, and when this page's body lost it the purge +// button rendered as an inert attribute (Alpine only evaluates directives +// inside an x-data tree; the click did literally nothing). +// +// Regression: /admin/archived rendered with no +// Alpine scope, so "Purge All Archived" was dead (prod report 2026-10). +func TestAdminArchivedPageHasAlpineScope(t *testing.T) { + archivedAt := time.Date(2026, 10, 28, 4, 38, 0, 0, time.UTC) + var b strings.Builder + err := AdminArchived(User{ID: "u1", Timezone: "UTC"}, []ArchivedItem{{ + ID: "m1", + Title: "Lost Book", + Author: "An Author", + LibraryName: "Audiobooks", + FilePath: "Author/Book/file.m4b", + MissingScans: 2, + ArchivedAt: &archivedAt, + Status: "Archived - pending purge", + }}, 0).Render(context.Background(), &b) + if err != nil { + t.Fatalf("render: %v", err) + } + out := b.String() + if !strings.Contains(out, ` { +// Admin API calls authenticate with the Bearer token stashed at login, +// while the SSR pages themselves run on the cookie session — so a valid +// 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 { @@ -225,7 +240,7 @@ function updateLibraryProgress(jobId: string, status: any): void { // aborts the walk at its next context check and skips the archive pass, // so cancelling never archives unvisited files. async function cancelScan(jobId: string): Promise { - const token = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; const btn = document.querySelector(`[data-cancel-job="${jobId}"]`); @@ -257,7 +272,7 @@ 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 = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; try { @@ -287,6 +302,8 @@ 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; try { @@ -369,6 +386,9 @@ 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; @@ -399,7 +419,7 @@ 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 = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; if ( @@ -431,7 +451,7 @@ 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 = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; try { const resp = await fetch(`/api/media-items/${id}/unarchive`, { @@ -452,7 +472,7 @@ 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 = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; if ( !window.confirm( @@ -481,7 +501,7 @@ 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 = localStorage.getItem("token"); + const token = requireToken(); if (!token) return; if ( !window.confirm(