Admin role, root/home URL split, activity permalinks

Three related changes.

1. **Admin role.** New `is_admin INTEGER NOT NULL DEFAULT 0` column on
   users; added to MeResponse. Admin strictly implies moderator —
   shared/roles.ts has a single isModerator()/isAdmin() pair so the
   implication can't drift between callers. The duplicated isModerator()
   helpers in server/activities.ts and server/feedback.ts now import
   from there.

   /api/admin endpoints (admin-only):
     GET   /admin/users           — list users with their roles
     PATCH /admin/users/:id/role  — set is_moderator and/or is_admin

   Last-admin guard: the role-update endpoint refuses to demote the only
   remaining admin (409 cannot_demote_last_admin). Bootstrap is via
   `sqlite3 ... UPDATE users SET is_admin=1` — documented in README.

   Frontend Admin.svelte: table of users with toggles for moderator and
   admin. Visible from the nav only when the current user is admin.
   Toggling our own role refreshes session.user so the nav adapts
   immediately.

2. **Root/home split.** The URL `/` always shows the public landing
   (public + semi activities), even when the user is logged in. `/home`
   is the authenticated dashboard. After login or signup the SPA pushes
   `/home`; after logout it pushes `/`. popstate is wired so the
   back/forward buttons work. Unknown paths fall through to the public
   landing, not a 404.

3. **Activity permalinks at /a/:id.** New SPA route renders a single
   activity via the existing GET /api/activities/:id endpoint (private
   rows still require the owner's session to decrypt). A "Del" button
   on each ActivityRow copies the absolute permalink to the clipboard.
   Clipboard API has a prompt() fallback for environments where it's
   blocked.

Server changes minimal: server/admin.ts is the new file; server/roles.ts
is the lifted helper; server/index.ts wires the admin routes; server/db.ts
gets one more ensureColumn() line.

26 tests still pass; typecheck clean; Vite build succeeds. Bundle grew
from 28.6 KB gzipped to 30.2 KB reflecting the Admin + permalink views.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 13:23:13 +02:00
commit bd82f71a01
16 changed files with 573 additions and 80 deletions

View file

@ -80,6 +80,19 @@ Sessions are opaque tokens stored in the `sessions` table; the cookie is
right behaviour: it kicks out any logged-in session that may have been
hijacked, and the user has to re-login with the new password.
## Roles
Three levels: user / moderator / admin. Admin **implies** moderator —
`isModerator()` in `server/roles.ts` returns true for admins. Keep that
implication invariant: an admin who can't moderate is meaningless and
breaks the UI's assumptions. Add new privileges by checking `isAdmin()`,
not by relaxing `isModerator()`.
The admin endpoints (`/api/admin/*`) are gated by the `isAdmin()` check in
`server/admin.ts`. A last-admin safety net prevents the only remaining
admin from demoting themselves via the API — explicit `sqlite3` is
required for that, so the operator can't accidentally lock themselves out.
## Tag input merging — design decision
Server tags and IndexedDB tags are merged in one dropdown, each row labelled