# 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 `