diff --git a/docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md b/docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md new file mode 100644 index 0000000..6d60b94 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md @@ -0,0 +1,165 @@ +# 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/users`** — **moved 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: })` + (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 `} />`. +- **`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 `