Files
solelog/docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md
2026-06-24 22:33:59 +02:00

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)

  1. Operations: create, set role (worker ↔ admin), reset password, deactivate/reactivate. No hard delete — the work_sessions FK cascades, so deleting a user would wipe their logged history. Deactivation (better-auth banned) keeps the data and blocks sign-in.
  2. 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).
  3. 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/usersmoved here from admin.ts and enriched. Direct DB read of user, ordered by name, returning { id, email, name, role, status, created_at } where status is 'inactive' when banned is truthy else 'active', and role defaults to 'worker' when null. The report/session pickers read only id/name, so they keep working.
  • POST /api/admin/users — create. Body CreateUserInput { email, name, password, role }. Calls auth.api.createUser({ body: { email, password, name, role } }) (hashes the password, generates the id + account row; works despite disableSignUp, as seed.ts already 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 — body SetRoleInput { role }. Plain column update db.update(user).set({ role }).where(eq(user.id, id)) (role is a user column; no better-auth call needed). Guards (below) run first. Returns the updated list item.
  • POST /api/admin/users/:id/password — body SetPasswordInput { password }. Calls auth.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 update db.update(user).set({ banned: true, banReason: null, banExpires: null }) and db.delete(session).where(eq(session.userId, id)) so any live bearer token dies at the next getSession. The admin plugin already blocks banned users at sign-in. Guards run first.
  • POST /api/admin/users/:id/reactivate — plain update db.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' where id === 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 existing AdminUser schema, which is id/email/name/role/created_at, with status; if AdminUser is 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' into navItems ({ to: '/gebruikers', label: 'Gebruikers' }); soonItems is now empty, so remove the "Binnenkort" block entirely.
  • App.tsx — add <Route path="/gebruikers" element={<Users />} />.
  • screens/Users.tsx (Gebruikers): a row per user from useUsers — name, email, role pill (Beheerder/Werker), status pill (Actief/Inactief), created date. + Nieuwe gebruiker opens 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 via useMe) 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). Submits CreateUserInput.
  • api/users.tsuseUsers (['admin','users'], list shape) + mutations useCreateUser, useSetUserRole, useResetUserPassword, useDeactivateUser, useReactivateUser (all invalidate ['admin','users']). The existing minimal useAdminUsers (in admin-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 via createApp() + app.request):
    • GET returns 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 posts CreateUserInput.
    • 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.