docs(spec): phase 3b.3 user management design
This commit is contained in:
@@ -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: <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.
|
||||
Reference in New Issue
Block a user