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(