feat: admin overview of all cards (password-gated) #19
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
m wants an admin overview listing all cards. A dashboard at e.g.
/adminshowing every postcard with its metadata, for m to oversee/manage.⚠️ Access control — NON-NEGOTIABLE
The app has no user accounts (open compose, unguessable per-card tokens). An admin view that lists ALL cards must be gated — an open
/adminwould leak everyone's cards. Required:/admin(and any admin API/actions) behind a single admin password from env (ADMIN_PASSWORD), verified server-side.internal/server/cardauth.gosigning pattern from #11 (admin-scoped, HttpOnly, SameSite=Lax, Secure).ADMIN_PASSWORDis unset →/adminis disabled (404), so it can never be accidentally open in any environment.What it shows (read view = MVP)
A table/list of all cards, newest first, with: created date, occasion (+ custom label), recipient/sender, a short message preview, layout, media count (img/video), has-password (yes/no, don't show the hash), custom slug (+ to/from), expiry (
valid_until), max-views/visit state, and a link to open the card (/c/{token}). Mobile-first, German, paginate or lazy-load if the list grows.Management (decide / can be follow-up)
MVP = read-only list. Delete a card (row action → removes DB row + its Supabase Storage objects + any Shlink slug) is the obvious next admin action — include it if cheap, else flag as a follow-up. Don't add destructive actions without a confirm.
Implementation notes
/adminroute + login (/admin/loginPOST) + signed-cookie middleware; new admin template(s). A store query to list all cards (join images/videos counts). Mostlyinternal/server/handlers.go+server.go+ astorelist method + new templates — overlaps the compose-track files, so single-worker track.ADMIN_PASSWORDin the Dokploy env on deploy./adminshould also be claimed by the Traefik app router (addPathPrefix(/admin)topostcards-app.ymlfor both domains, else it falls through to Shlink) — otto-head handles that Traefik change.Constraints
DoD
/adminlists all cards with the metadata above, behind the admin-password gate; wrong/no auth → blocked;ADMIN_PASSWORDunset → 404.Auth UX (m, PWA): well-protected but NOT annoying — m must not re-enter the password every time.
ADMIN_PASSWORDenv) for the initial login, then a long-lived signed session so m stays logged in: HMAC-signed cookie (the existingcardauth.gopattern — equivalent to a JWT for this) with a long TTL (e.g. 30 days, sliding/renew-on-use), HttpOnly, SameSite=Lax, Secure. One login, remembered for weeks. (JWT is fine too; signed cookie is simpler and already in the codebase — your call, just make it persistent.)ADMIN_PASSWORDunset → /admin 404; server-side verify; no card data before auth. Secure AND low-friction.#19 — admin overview shipped (read view + delete)
Branch
mai/hermes/issue-19-feat-admin, commita5f353a. Not merged/deployed — otto-head does that (env + Traefik + deploy below).Access control (the non-negotiable bit)
ADMIN_PASSWORD(env) verified server-side. On success a short-lived 12h HMAC-signed session cookie is set: HttpOnly,SameSite=Lax,Secureon https, scoped to/admin. Reuses the #11cardauth.gosigning pattern and key, but domain-separated — the byte before the expiry is NUL in the admin MAC vs.in the card MAC, so an admin cookie can never unlock a card and a card cookie can never pass as admin, for any token (there's a test that asserts exactly this).ADMIN_PASSWORDunset → the/adminroutes are not registered at all →/adminis a 404. The all-cards listing can never be exposed by accident in any environment.Read view (MVP)
List at
/admin, newest first, paginated 50/page. Each card shows: created date, occasion (+ custom label), recipient / sender, message preview, layout, image/video counts, has-password (🔒/🔓, never the hash), custom slug, expiry (valid_until, expired cards flagged + dimmed), visit cap, audience (broadcast → „An alle"), and an Öffnen link to/c/{token}. Mobile-first stacked cards (no horizontal table), German + Umlaute,noindex.Delete — included (was cheap)
Storage keys live in the DB and the image/video rows cascade, so delete was inexpensive and I shipped it. Row → server-rendered confirmation page → POST removes: the DB row (images/videos cascade), the stored media objects (new
store.Deleteon both local + Supabase backends), and the card's Shlink slug. The confirm is a real page, so the destructive action is never a single unconfirmed click and works without JavaScript. DB delete is authoritative-first; storage + slug cleanup is best-effort (a leftover object is unreachable and just wastes space; a card with missing media would be worse).Files
internal/config(AdminPassword + AdminEnabled);internal/server/adminauth.go+handlers_admin.go(routes registered only when enabled);internal/postcard(CardSummary + CountAll/ListSummaries/Delete, sharedoccasionView, reserved slugadmin);internal/store(Delete on both backends);web/templates/admin_login.html+admin.html+admin_confirm.html+ CSS.Verification
build/vet/test -racegreen. New tests: admin-session sign/verify/expiry/tamper/wrong-secret/domain-separation, HTTP gate + login + 404-when-disabled, list/confirm render + no-hash-leak, CardSummary helpers. E2E on throwaway Postgres + local storage: disabled→404; gate no-leak; wrong pw→401; correct→cookie; list shows all metadata; confirm authed→200 / unauthed→401; delete → row 404s + media file removed from disk + survivor card intact; session survives a restart (stableCARD_SESSION_SECRET); logout clears. Test data cleaned up.⚠️ otto-head TODO (deploy)
ADMIN_PASSWORD(strong secret). Recommend also setting a stableCARD_SESSION_SECRETso admin/unlock sessions survive redeploys.PathPrefix(/admin)is already added todeploy/traefik/postcards-app.yml— redeploy the dynamic config so/adminis claimed by the app and not forwarded to the Shlink catch-all. (adminis also a reserved slug now.)Note on the password-gated-media limitation (#11)
The admin delete removes the storage objects, but the existing #11 caveat is unchanged: media objects remain public-by-URL in the bucket while a card is live. Out of scope for #19.
DoD met: lists all cards behind the gate; wrong/no auth blocked; unset→404; delete removes row+storage+slug with confirm; build/vet/test + e2e green.
✅ Merged & deployed — admin overview live (gated)
Merged
a5f353a(merge13261ce), deployed. otto-head setADMIN_PASSWORDin Dokploy env + addedPathPrefix(/admin)to the live Traefik app router (both domains, plus/favicon.icopreemptively).Verified live on sendmy.cards: GET /admin no-auth → login page, no card data leaked; wrong pw → 401; correct → 303 + signed cookie → lists all cards. Delete path shipped (confirm page → row + storage + slug). build/vet/test -race green.
One deviation flagged: admin session is currently 12h, but m asked for a long remembered session (~30 days). Tracked to bump the TTL to 30 days (sliding) — folding into the polish batch. Not closing — m closes.
Admin session less annoying — TTL 12h → 30 days (sliding)
Per m's 'not annoying' note, the admin session in
internal/server/adminauth.gowas a short 12h. Bumped to 30 days, sliding:adminAuthTTL = 30 * 24 * time.Hour.GET /adminview re-issues the cookie (handleAdmincallsissueAdminCookie), so active admin use never hits the password gate — only a ~30-day gap of no activity forces a fresh login.build / vet / test green (existing admin-session tests still pass).
Commit:
3ae968445bFollow-up: admin session TTL bumped 12h → 30 days (sliding) in the polish bundle (commit
3ae9684, merge9a8eda4) — addresses the not-annoying requirement. Login once, remembered ~30 days.