Files
solelog/docs/sessions/2026-06-24-phase-3b3-user-management.md
Bas van Rossem 8d303dabcb
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
docs: phase 3b.3 user-management session log + roadmap (Phase 3 complete)
2026-06-24 23:14:59 +02:00

6.0 KiB

Session: 2026-06-24 — Phase 3b·3 (User management — Gebruikers)

Goal

Let an admin manage workplace logins: list users with role + status, create a user, change a user's role, reset a forgotten password, and deactivate/reactivate an account — without ever losing production history, and with server-side guards that make admin lockout impossible. Third and final Phase 3b cycle; completes the Phase 3 admin panel. Spec: docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md; plan: docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md. Tracked as Plane epic SL-54 with tasks SL-55…SL-60.

Scope decisions (brainstorming)

  • Operations: create, set role (worker ↔ admin), reset password, deactivate/reactivate. No hard delete — the work_sessions FK cascades, so deletion would wipe logged history; deactivation (better-auth banned) keeps the data and blocks sign-in.
  • Lockout guards (server-side): no self-deactivate, no self-demote, last-active-admin invariant.
  • Password floor: 8 chars (better-auth default) on create + reset. No email reset flow (no mailer).
  • No DB migrationrole, banned, banReason, banExpires already exist on user.

Work done

Built via a 6-task TDD Workflow (wptwrno4a, ultracode) — one commit per task, sequential (dependent, shared tree) — followed by a two-lens adversarial security review, then a hardening pass:

  • Task 1 — adminGuard + contracts (a32406d). Extracted the inline /api/admin/* gate into a reusable adminGuard middleware in lib/require-user.ts (used by both admin routers). Added UserStatus, status on AdminUser, CreateUserInput, SetRoleInput, SetPasswordInput to @solelog/shared.
  • Task 2 — admin-users router: list + create (2b4b633). New routes/admin-users.ts. Enriched GET /api/admin/users (id/email/name/role/status/created_at), moved out of admin.ts so the path is defined once; the report/session pickers keep working (they read id/name). POST /api/admin/users via auth.api.createUser (hashing), duplicate email → 409. Mounted in app.ts.
  • Task 3 — set role + guards (88b7773). POST /:id/role as a direct column update, with the activeAdminsExcluding helper, self-demote guard, and last-admin guard.
  • Task 4 — deactivate/reactivate (40977f8). POST /:id/deactivate sets banned=true and deletes the user's session rows (kills live bearer tokens); POST /:id/reactivate clears it. Self-deactivate + last-admin guards.
  • Task 5 — reset password (4b80898). POST /:id/password via auth.api.setUserPassword (shape verified against the installed better-auth types: { userId, newPassword }).
  • Task 6 — Gebruikers UI (0f42960). api/users.ts (useUsers + 5 mutations), UserForm.tsx (create), Users.tsx (list with role/status pills, own-row "jij" badge hiding role/deactivate, row actions for role toggle / inline password reset / deactivate-reactivate). Sidebar moves Gebruikers into nav and drops the now-empty "Binnenkort" block; /gebruikers route added.

Adversarial security review (two lenses, read-only)

Both lenses returned no exploitable holes: lastAdminProtected, gatingConfirmed, deactivateBlocksSignin all true. Key confirmations: no reachable sequence reaches zero active admins (the caller is always an active admin ≠ target, so activeAdminsExcluding(target) ≥ 1, and the self-guards stop the caller removing their own admin/active status); deactivate genuinely blocks sign-in (better-auth throws BANNED_USER in session.create.before, banExpires=null prevents auto-unban) and the session-row deletion is the load-bearing half that revokes live tokens (core session validation does not re-check banned); every /api/admin/users* route is gated.

It surfaced three real, fixable issues, addressed in a hardening pass (b9aa24c):

  1. Test quality — the two "last active admin" tests asserted 200/the self-guard, never the last-admin branch (which is unreachable over HTTP). Renamed them honestly and added a direct unit test of activeAdminsExcluding (covers the !banned filter + exclusion).
  2. 500 on mixed-case create — create re-selected by original-case email, but better-auth lowercases on store → toListItem(undefined). Now re-selects on email.toLowerCase(); added a mixed-case create test.
  3. Defense-in-depthadminGuard checked role only. It now also rejects banned admins (so a banned admin holding a live token is refused even if their session somehow survives); added a banned-admin-guard test.

Verification (independent of the workflow's self-reports)

  • git log — six task commits a32406d → 0f42960 + hardening b9aa24c; clean tree.
  • yarn workspace @solelog/api test103 passed (15 files), incl. admin-users and the regression admin/report/export/csv suites; typecheck clean.
  • yarn workspace @solelog/admin test50 passed (11 files), incl. Users; typecheck clean; build succeeds (vite, 94 modules).
  • npx oxlint — clean (exit 0).

Outcome

Phase 3b·3 is implemented, hardened, and green across both touched workspaces. An admin can list users with role + status, create workers/admins (who can then sign in), flip roles, reset passwords (new works / old fails), and deactivate/reactivate accounts — with sign-in genuinely blocked on deactivation and live tokens revoked, and with lockout made impossible by the self/last-admin guards plus the banned-admin gate. No DB migration; no hard delete. Plane SL-54 + SL-55…SL-60 all Done. Phase 3b and the Phase 3 admin panel are complete.

origin/main was advanced by the maintainer between cycles; after this cycle main is 9 ahead (the whole 3b·3 batch). A single git push origin main from the maintainer's terminal ships it (and triggers Gitea CI).

Next

Phase 3 is done. Per the roadmap, Phase 4 — Workbench scanning (QR at the bench → pre-fill the session, with manual fallback) and Phase 5 — Polish & deploy remain.