9.8 KiB
Phase 3b·3 — User Management (Gebruikers) — Design
- Created: 2026-06-24
- Status: Approved (brainstorming) — ready for implementation plan
- Tracker: Plane (workspace
solelog, project SoleLog) - Cycle: Third and final Phase 3b cycle (completes the Phase 3 admin panel)
- Touches:
packages/shared,apps/api,apps/admin
Goal
An admin can manage the workplace logins: see every user 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. No self-service email reset exists (no mailer), so the admin setting a password is the only recovery path.
Done when: an admin can list users (with role + active/inactive status), create a worker or admin who can then sign in, flip a user's role, reset a user's password (new works, old fails), deactivate an account (sign-in blocked) and reactivate it — while the API refuses any action that would lock out the admins (self-deactivate, self-demote, or removing the last active admin).
Scope decisions (confirmed during brainstorming, 2026-06-24)
- Operations: create, set role (worker ↔ admin), reset password, deactivate/reactivate.
No hard delete — the
work_sessionsFK cascades, so deleting a user would wipe their logged history. Deactivation (better-authbanned) keeps the data and blocks sign-in. - Lockout guards (server-side): no self-deactivate, no self-demote, and a last-active-admin invariant — any demote/deactivate that would leave zero active admins is refused (400 + Dutch message).
- Password floor: better-auth's default minimum of 8 characters for create + reset.
A. Backend
Small enabling refactor
Extract the inline admin gate (currently adminRoutes.use('/api/admin/*', …) in routes/admin.ts)
into a reusable adminGuard middleware in lib/require-user.ts:
export const adminGuard: MiddlewareHandler — 401 if no session, 403 if not admin, else next().
admin.ts swaps its inline guard for adminRoutes.use('/api/admin/*', adminGuard) (existing admin
tests cover it). The new user router uses the same middleware.
New routes/admin-users.ts (own Hono router, mounted in app.ts, gated by adminGuard)
User management is a distinct responsibility and admin.ts already carries sessions + report +
export, so it gets its own file.
GET /api/admin/users— moved here fromadmin.tsand enriched. Direct DB read ofuser, ordered by name, returning{ id, email, name, role, status, created_at }wherestatusis'inactive'whenbannedis truthy else'active', androledefaults to'worker'when null. The report/session pickers read onlyid/name, so they keep working.POST /api/admin/users— create. BodyCreateUserInput { email, name, password, role }. Callsauth.api.createUser({ body: { email, password, name, role } })(hashes the password, generates the id + account row; works despitedisableSignUp, asseed.tsalready relies on). A duplicate email throws → mapped to 409 ({ error: 'E-mailadres bestaat al.' }). Returns the created user in the list shape.POST /api/admin/users/:id/role— bodySetRoleInput { role }. Plain column updatedb.update(user).set({ role }).where(eq(user.id, id))(role is ausercolumn; no better-auth call needed). Guards (below) run first. Returns the updated list item.POST /api/admin/users/:id/password— bodySetPasswordInput { password }. Callsauth.api.setUserPassword({ body: { userId: id, newPassword: password }, headers: <forwarded> })(needs better-auth hashing). 404 if the user does not exist.POST /api/admin/users/:id/deactivate— plain updatedb.update(user).set({ banned: true, banReason: null, banExpires: null })anddb.delete(session).where(eq(session.userId, id))so any live bearer token dies at the nextgetSession. The admin plugin already blocks banned users at sign-in. Guards run first.POST /api/admin/users/:id/reactivate— plain updatedb.update(user).set({ banned: false, banReason: null, banExpires: null }).
All write routes 404 on an unknown :id. The whole surface is behind adminGuard → 401 (no
session) / 403 (non-admin).
Guards (helper in admin-users.ts, run before role/deactivate mutations)
Given the acting admin (caller = await getSessionUser(c)) and the target id:
- Self-deactivate: deactivate where
id === caller.id→ 400"Je kunt jezelf niet deactiveren." - Self-demote: role change to
'worker'whereid === caller.id→ 400"Je kunt jezelf niet degraderen." - Last active admin: for a demote (target currently admin → worker) or a deactivate of an admin,
count active admins (
role = 'admin' AND (banned IS NULL OR banned = 0)) excluding the target; if that count is 0 → 400"Er moet minstens één actieve beheerder blijven."
Because the caller is always an active admin, the self-guards already guarantee ≥1 admin remains; the last-admin check is explicit defense-in-depth and documents the invariant.
Why direct-DB for role/status (not auth.api)
role and banned are ordinary user columns; updating them directly is version-proof and
trivially testable in-process. Only create and reset-password need better-auth (password
hashing), so only those go through auth.api.*. The exact auth.api method names + param keys are
verified against the installed better-auth version during implementation.
B. Shared contracts (@solelog/shared)
UserStatus = z.enum(['active', 'inactive']).- The user list shape gains
status: UserStatus(extend the existingAdminUserschema, which isid/email/name/role/created_at, withstatus; ifAdminUseris unused elsewhere, repurpose it as the list item — verified during planning). CreateUserInput = { email: string().email(), name: string().trim().min(1), password: string().min(8), role: Role }.SetRoleInput = { role: Role }.SetPasswordInput = { password: string().min(8) }.
No DB migration — role, banned, banReason, banExpires already exist on user.
C. Admin UI (apps/admin)
components/Sidebar.tsx— move'Gebruikers'intonavItems({ to: '/gebruikers', label: 'Gebruikers' });soonItemsis now empty, so remove the "Binnenkort" block entirely.App.tsx— add<Route path="/gebruikers" element={<Users />} />.screens/Users.tsx(Gebruikers): a row per user fromuseUsers— name, email, role pill (Beheerder/Werker), status pill (Actief/Inactief), created date.+ Nieuwe gebruikeropens the create form. Per-row actions: Maak admin / Maak werker (role toggle), Reset wachtwoord (reveals an inline password input + Opslaan), Deactiveer / Heractiveer. The signed-in admin's own row (matched viauseMe) shows a "jij" badge with the role + deactivate actions hidden (password reset on self is allowed).components/UserForm.tsx— the create form: name, email, password, role<select>, with inline validation mirroring the API (email format, password ≥ 8). SubmitsCreateUserInput.api/users.ts—useUsers(['admin','users'], list shape) + mutationsuseCreateUser,useSetUserRole,useResetUserPassword,useDeactivateUser,useReactivateUser(all invalidate['admin','users']). The existing minimaluseAdminUsers(inadmin-sessions.ts) stays for the pickers.
Error handling
- Duplicate email on create → 409 → form shows "E-mailadres bestaat al."
- Guard violations → 400 with the Dutch messages above → surfaced inline near the row/action.
- Short password → 400 → inline on the password field.
- Any better-auth error is caught and returned as a JSON
{ error }(never a 500 stack).
Testing
- API (
admin-users.test.ts, in-process viacreateApp()+app.request):GETreturns role + status; worker token → 403.- create → the new user can sign in with the given password; duplicate email → 409; password < 8 → 400; created user appears with the right role/status.
- set role worker→admin and admin→worker (plain rows, verified in DB/readback).
- reset password → sign-in with the new password succeeds and the old one fails.
- deactivate → sign-in is blocked; the user's sessions are gone; reactivate → sign-in works again.
- guards: self-deactivate, self-demote, and last-active-admin demote/deactivate each → 400 with the expected message; the action did not take effect.
- Admin (vitest + Testing Library):
- Users renders a row per user with role + status pills.
+ Nieuwe gebruiker→ create form postsCreateUserInput.- row actions (Maak admin, Reset wachtwoord, Deactiveer) call the right mutations with the right id.
- the signed-in admin's own row hides the role/deactivate actions.
Adversarial verification (ultracode)
After the build, a dedicated review agent independently scrutinizes the lockout guards and the create/ban auth wiring: it tries to construct a sequence that reaches zero active admins, checks that deactivate truly blocks sign-in (not just hides the UI), confirms the worker-token 403 gating on every route, and verifies the tests actually exercise these paths rather than asserting trivially. Findings are reported (not auto-applied); real holes get a follow-up fix task.
Out of scope
- Hard delete; email-based password reset (no mailer); session/impersonation management beyond the deactivate-revokes-sessions behavior; bulk operations; editing a user's email/name after creation.
Build approach
spec → writing-plans → one Workflow (~6 TDD tasks, sequential — dependent, shared tree —
commit per task) + a final adversarial security-review stage. Tracked as a Plane epic. Completes
Phase 3b and the Phase 3 admin panel.