fix(admin): un-dead the archived page's purge and delete buttons
Release / build-and-push (push) Successful in 3m15s

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 <body> 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.
This commit is contained in:
John O'Keefe committed 2026-10-28 09:37:14 -04:00
1 parent 795225f097
commit f1e4b2a603
4 files changed
+76 -9

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head> </head>
<body class={ "theme-" + user.Theme }> <body x-data="admin" class={ "theme-" + user.Theme }>
@Header(user, "/admin/archived") @Header(user, "/admin/archived")
<main class="p-8"> <main class="p-8">
<div class="max-w-5xl"> <div class="max-w-5xl">
+47
View File
@@ -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 <body class="theme-…"> 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, `<body x-data="admin"`) {
t.Error("archived page body missing x-data=\"admin\" — the Alpine-bound purge button would be dead")
}
// The action buttons the page depends on must be present in the same
// render, so the pin cannot pass on an empty shell.
for _, want := range []string{"purgeArchivedItems()", `data-delete-archived="m1"`} {
if !strings.Contains(out, want) {
t.Errorf("archived page missing %q", want)
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body class=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<body x-data=\"admin\" class=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+27 -7
View File
@@ -1,8 +1,23 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { showToast } from "./toast"; import { showToast } from "./toast";
async function scanAllLibraries(): Promise<void> { // 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"); 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> {
const token = requireToken();
if (!token) return; if (!token) return;
try { 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, // 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`); const btn = document.querySelector<HTMLButtonElement>(`[data-cancel-job="${jobId}"]`);
@@ -257,7 +272,7 @@ 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
try { try {
@@ -287,6 +302,8 @@ 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
// token is absent (requireToken's toast would fire on every page visit).
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
try { try {
@@ -369,6 +386,9 @@ 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
// action: stay silent when the API token is absent (requireToken's
// toast would fire on every page visit).
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
@@ -399,7 +419,7 @@ 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
if ( if (
@@ -431,7 +451,7 @@ 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
try { try {
const resp = await fetch(`/api/media-items/${id}/unarchive`, { const resp = await fetch(`/api/media-items/${id}/unarchive`, {
@@ -452,7 +472,7 @@ 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
if ( if (
!window.confirm( !window.confirm(
@@ -481,7 +501,7 @@ 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 = localStorage.getItem("token"); const token = requireToken();
if (!token) return; if (!token) return;
if ( if (
!window.confirm( !window.confirm(