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

166 lines
9.8 KiB
Markdown

# 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: <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.ts`** — `useUsers` (`['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.