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_sessionsFK cascades, so deletion would wipe logged history; deactivation (better-authbanned) 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 migration —
role,banned,banReason,banExpiresalready exist onuser.
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 reusableadminGuardmiddleware inlib/require-user.ts(used by both admin routers). AddedUserStatus,statusonAdminUser,CreateUserInput,SetRoleInput,SetPasswordInputto@solelog/shared. - Task 2 — admin-users router: list + create (
2b4b633). Newroutes/admin-users.ts. EnrichedGET /api/admin/users(id/email/name/role/status/created_at), moved out ofadmin.tsso the path is defined once; the report/session pickers keep working (they read id/name).POST /api/admin/usersviaauth.api.createUser(hashing), duplicate email → 409. Mounted inapp.ts. - Task 3 — set role + guards (
88b7773).POST /:id/roleas a direct column update, with theactiveAdminsExcludinghelper, self-demote guard, and last-admin guard. - Task 4 — deactivate/reactivate (
40977f8).POST /:id/deactivatesetsbanned=trueand deletes the user'ssessionrows (kills live bearer tokens);POST /:id/reactivateclears it. Self-deactivate + last-admin guards. - Task 5 — reset password (
4b80898).POST /:id/passwordviaauth.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;/gebruikersroute 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):
- 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 ofactiveAdminsExcluding(covers the!bannedfilter + exclusion). - 500 on mixed-case create — create re-selected by original-case email, but better-auth
lowercases on store →
toListItem(undefined). Now re-selects onemail.toLowerCase(); added a mixed-case create test. - Defense-in-depth —
adminGuardchecked 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 commitsa32406d → 0f42960+ hardeningb9aa24c; clean tree.yarn workspace @solelog/api test— 103 passed (15 files), incl.admin-usersand the regressionadmin/report/export/csvsuites;typecheckclean.yarn workspace @solelog/admin test— 50 passed (11 files), incl.Users;typecheckclean;buildsucceeds (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.