feat: admin overview of all cards (password-gated) #19

Closed
opened 2026-06-17 08:43:07 +00:00 by mAi · 5 comments
Collaborator

What

m wants an admin overview listing all cards. A dashboard at e.g. /admin showing 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 /admin would leak everyone's cards. Required:

  • Gate /admin (and any admin API/actions) behind a single admin password from env (ADMIN_PASSWORD), verified server-side.
  • On success, set a short-lived HMAC-signed session cookie — reuse the internal/server/cardauth.go signing pattern from #11 (admin-scoped, HttpOnly, SameSite=Lax, Secure).
  • If ADMIN_PASSWORD is unset → /admin is disabled (404), so it can never be accidentally open in any environment.
  • Never ship card data before admin auth. No content leak on the gate page.

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

  • New /admin route + login (/admin/login POST) + signed-cookie middleware; new admin template(s). A store query to list all cards (join images/videos counts). Mostly internal/server/handlers.go + server.go + a store list method + new templates — overlaps the compose-track files, so single-worker track.
  • otto-head sets ADMIN_PASSWORD in the Dokploy env on deploy. /admin should also be claimed by the Traefik app router (add PathPrefix(/admin) to postcards-app.yml for both domains, else it falls through to Shlink) — otto-head handles that Traefik change.

Constraints

  • Mobile-first, German + Umlaute. Don't expose password hashes or secrets. Coexists with everything shipped.

DoD

  • /admin lists all cards with the metadata above, behind the admin-password gate; wrong/no auth → blocked; ADMIN_PASSWORD unset → 404.
  • (If included) delete action removes DB row + storage + slug, with confirm.
  • build/vet/test green; e2e (gate blocks unauthed, lists when authed); test data cleaned. Commit references this issue. otto-head sets ADMIN_PASSWORD env + Traefik /admin path + deploys.
## What m wants an **admin overview listing all cards**. A dashboard at e.g. `/admin` showing 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 `/admin` would leak everyone's cards. Required: - Gate `/admin` (and any admin API/actions) behind a **single admin password** from env (`ADMIN_PASSWORD`), verified **server-side**. - On success, set a short-lived **HMAC-signed session cookie** — reuse the `internal/server/cardauth.go` signing pattern from #11 (admin-scoped, HttpOnly, SameSite=Lax, Secure). - If `ADMIN_PASSWORD` is **unset → `/admin` is disabled (404)**, so it can never be accidentally open in any environment. - Never ship card data before admin auth. No content leak on the gate page. ## 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 - New `/admin` route + login (`/admin/login` POST) + signed-cookie middleware; new admin template(s). A store query to list all cards (join images/videos counts). Mostly `internal/server/handlers.go` + `server.go` + a `store` list method + new templates — overlaps the compose-track files, so single-worker track. - otto-head sets `ADMIN_PASSWORD` in the Dokploy env on deploy. `/admin` should also be claimed by the Traefik app router (add `PathPrefix(/admin)` to `postcards-app.yml` for both domains, else it falls through to Shlink) — otto-head handles that Traefik change. ## Constraints - Mobile-first, German + Umlaute. Don't expose password hashes or secrets. Coexists with everything shipped. ## DoD - `/admin` lists all cards with the metadata above, behind the admin-password gate; wrong/no auth → blocked; `ADMIN_PASSWORD` unset → 404. - (If included) delete action removes DB row + storage + slug, with confirm. - build/vet/test green; e2e (gate blocks unauthed, lists when authed); test data cleaned. Commit references this issue. otto-head sets ADMIN_PASSWORD env + Traefik /admin path + deploys.
Author
Collaborator

Auth UX (m, PWA): well-protected but NOT annoying — m must not re-enter the password every time.

  • Admin password (ADMIN_PASSWORD env) for the initial login, then a long-lived signed session so m stays logged in: HMAC-signed cookie (the existing cardauth.go pattern — 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.)
  • Keep the security bar: ADMIN_PASSWORD unset → /admin 404; server-side verify; no card data before auth. Secure AND low-friction.
**Auth UX (m, PWA): well-protected but NOT annoying** — m must not re-enter the password every time. - Admin password (`ADMIN_PASSWORD` env) for the initial login, then a **long-lived signed session** so m stays logged in: HMAC-signed cookie (the existing `cardauth.go` pattern — 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.) - Keep the security bar: `ADMIN_PASSWORD` unset → /admin 404; server-side verify; no card data before auth. Secure AND low-friction.
mAi self-assigned this 2026-06-17 08:52:56 +00:00
Author
Collaborator

#19 — admin overview shipped (read view + delete)

Branch mai/hermes/issue-19-feat-admin, commit a5f353a. 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, Secure on https, scoped to /admin. Reuses the #11 cardauth.go signing 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_PASSWORD unset → the /admin routes are not registered at all → /admin is a 404. The all-cards listing can never be exposed by accident in any environment.
  • The login gate ships no card data — verified by an e2e no-leak scan.

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.Delete on 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, shared occasionView, reserved slug admin); internal/store (Delete on both backends); web/templates/admin_login.html + admin.html + admin_confirm.html + CSS.

Verification

build / vet / test -race green. 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 (stable CARD_SESSION_SECRET); logout clears. Test data cleaned up.

⚠️ otto-head TODO (deploy)

  1. Dokploy env: set ADMIN_PASSWORD (strong secret). Recommend also setting a stable CARD_SESSION_SECRET so admin/unlock sessions survive redeploys.
  2. Traefik: PathPrefix(/admin) is already added to deploy/traefik/postcards-app.yml — redeploy the dynamic config so /admin is claimed by the app and not forwarded to the Shlink catch-all. (admin is also a reserved slug now.)
  3. Merge + deploy.

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.

## #19 — admin overview shipped (read view **+ delete**) Branch `mai/hermes/issue-19-feat-admin`, commit [`a5f353a`](https://mgit.msbls.de/m/postcards/commit/a5f353aa0ff9523c8d7460249ecd913f9343466b). **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`, `Secure` on https, scoped to `/admin`. Reuses the #11 `cardauth.go` signing 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_PASSWORD` unset → the `/admin` routes are not registered at all → `/admin` is a 404.** The all-cards listing can never be exposed by accident in any environment. - The login gate ships **no card data** — verified by an e2e no-leak scan. ### 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.Delete` on 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, shared `occasionView`, reserved slug `admin`); `internal/store` (Delete on both backends); `web/templates/admin_login.html` + `admin.html` + `admin_confirm.html` + CSS. ### Verification `build` / `vet` / `test -race` green. 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 (stable `CARD_SESSION_SECRET`); logout clears. Test data cleaned up. ### ⚠️ otto-head TODO (deploy) 1. **Dokploy env:** set `ADMIN_PASSWORD` (strong secret). Recommend also setting a stable `CARD_SESSION_SECRET` so admin/unlock sessions survive redeploys. 2. **Traefik:** `PathPrefix(/admin)` is already added to `deploy/traefik/postcards-app.yml` — redeploy the dynamic config so `/admin` is claimed by the app and not forwarded to the Shlink catch-all. (`admin` is also a reserved slug now.) 3. Merge + deploy. ### 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.
Author
Collaborator

Merged & deployed — admin overview live (gated)

Merged a5f353a (merge 13261ce), deployed. otto-head set ADMIN_PASSWORD in Dokploy env + added PathPrefix(/admin) to the live Traefik app router (both domains, plus /favicon.ico preemptively).

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.

## ✅ Merged & deployed — admin overview live (gated) Merged `a5f353a` (merge `13261ce`), deployed. otto-head set `ADMIN_PASSWORD` in Dokploy env + added `PathPrefix(/admin)` to the live Traefik app router (both domains, plus `/favicon.ico` preemptively). **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.
Author
Collaborator

Admin session less annoying — TTL 12h → 30 days (sliding)

Per m's 'not annoying' note, the admin session in internal/server/adminauth.go was a short 12h. Bumped to 30 days, sliding:

  • adminAuthTTL = 30 * 24 * time.Hour.
  • Sliding renewal: every authenticated GET /admin view re-issues the cookie (handleAdmin calls issueAdminCookie), so active admin use never hits the password gate — only a ~30-day gap of no activity forces a fresh login.
  • The signed-cookie security model (HMAC over expiry, domain separation from the per-card unlock MAC, HttpOnly/SameSite/Secure) is unchanged — only the lifetime changed.

build / vet / test green (existing admin-session tests still pass).

Commit: 3ae968445b

## Admin session less annoying — TTL 12h → 30 days (sliding) Per m's 'not annoying' note, the admin session in `internal/server/adminauth.go` was a short 12h. Bumped to **30 days, sliding**: - `adminAuthTTL = 30 * 24 * time.Hour`. - Sliding renewal: every authenticated `GET /admin` view re-issues the cookie (`handleAdmin` calls `issueAdminCookie`), so active admin use never hits the password gate — only a ~30-day gap of no activity forces a fresh login. - The signed-cookie security model (HMAC over expiry, domain separation from the per-card unlock MAC, HttpOnly/SameSite/Secure) is unchanged — only the lifetime changed. build / vet / test green (existing admin-session tests still pass). Commit: https://mgit.msbls.de/m/postcards/commit/3ae968445befd70116929bf5f2ed1adbfab8bd2f
Author
Collaborator

Follow-up: admin session TTL bumped 12h → 30 days (sliding) in the polish bundle (commit 3ae9684, merge 9a8eda4) — addresses the not-annoying requirement. Login once, remembered ~30 days.

Follow-up: admin session TTL bumped **12h → 30 days** (sliding) in the polish bundle (commit `3ae9684`, merge `9a8eda4`) — addresses the not-annoying requirement. Login once, remembered ~30 days.
mAi closed this issue 2026-06-23 15:48:03 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/postcards#19
No description provided.