diff --git a/docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md b/docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md new file mode 100644 index 0000000..1ef77f5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md @@ -0,0 +1,1417 @@ +# Phase 3b·3 — User Management (Gebruikers) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an admin list users (role + status), create users, change roles, reset passwords, and deactivate/reactivate accounts — with server-side guards that make admin lockout impossible. + +**Architecture:** A new admin-gated `routes/admin-users.ts` router holds the user endpoints. `role` and `banned` are plain `user` columns, so set-role / deactivate / reactivate are direct Drizzle updates (version-proof, in-process testable); only create and reset-password go through `auth.api.*` (password hashing). A tables-style Gebruikers screen drives it via React Query. + +**Tech Stack:** Hono, better-auth (admin + bearer plugins), Drizzle (libsql/SQLite), zod (`@solelog/shared`), React + React Query + Vite (admin), Vitest + Testing Library. + +## Global Constraints + +- **No hard delete** — deactivation only (`banned`), so `work_sessions` history is never cascade-deleted. +- **Lockout guards (server-side, 400 + Dutch message):** no self-deactivate (`"Je kunt jezelf niet deactiveren."`); no self-demote (`"Je kunt jezelf niet degraderen."`); last-active-admin invariant (`"Er moet minstens één actieve beheerder blijven."`). +- **Password floor:** minimum 8 characters (zod `.min(8)`) on create + reset. +- **Admin-gated:** every `/api/admin/users*` route is behind `adminGuard` → 401 (no session) / 403 (non-admin). +- **No DB migration:** `role`, `banned`, `banReason`, `banExpires` already exist on `user`. +- **Status mapping:** `status = banned ? 'inactive' : 'active'`; `role` defaults to `'worker'` when null. +- **better-auth call shapes** (`auth.api.createUser({ body: { email, password, name, role } })`, `auth.api.setUserPassword({ body: { userId, newPassword }, headers } )`) must be verified against the installed types during implementation; the custom `'worker'` role needs the same local cast as `seed.ts`/`test/helpers.ts`. +- **Code style:** oxfmt — 2-space, single quotes, semicolons, width 100, trailing-comma `all`. `npx oxfmt ` before each commit. + +--- + +### Task 1: Foundation — `adminGuard` middleware + shared contracts + +Extract the admin gate so two routers can share it, and add the user contracts. No new behavior — verified by the existing admin suite + typecheck. + +**Files:** +- Modify: `apps/api/src/lib/require-user.ts` (add `adminGuard`) +- Modify: `apps/api/src/routes/admin.ts:16-21` (use `adminGuard`; drop now-unused imports) +- Modify: `packages/shared/src/index.ts` (add contracts) + +**Interfaces:** +- Produces: `adminGuard: MiddlewareHandler` (consumed by Tasks 2-5 + `admin.ts`); shared `UserStatus`, `AdminUser` (now with `status`), `CreateUserInput`, `SetRoleInput`, `SetPasswordInput` (consumed by Tasks 2-6). + +- [ ] **Step 1: Add `adminGuard` to `require-user.ts`** + +Append to `apps/api/src/lib/require-user.ts` (add `MiddlewareHandler` to the type import): + +```ts +import type { Context, MiddlewareHandler } from 'hono'; +``` + +```ts +// Reusable gate for the whole /api/admin/* surface: 401 if unauthenticated, 403 if not an admin. +export const adminGuard: MiddlewareHandler = async (c, next) => { + const u = await getSessionUser(c); + if (!u) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(u)) return c.json({ error: 'Forbidden' }, 403); + await next(); +}; +``` + +- [ ] **Step 2: Use it in `admin.ts`** + +In `apps/api/src/routes/admin.ts`, replace the inline guard block: + +```ts +// Gate the whole /api/admin/* surface to admins. +adminRoutes.use('/api/admin/*', async (c, next) => { + const sessionUser = await getSessionUser(c); + if (!sessionUser) return c.json({ error: 'Unauthorized' }, 401); + if (!isAdmin(sessionUser)) return c.json({ error: 'Forbidden' }, 403); + await next(); +}); +``` + +with: + +```ts +// Gate the whole /api/admin/* surface to admins. +adminRoutes.use('/api/admin/*', adminGuard); +``` + +Update the import in `admin.ts` from `import { getSessionUser, isAdmin } from '../lib/require-user';` to `import { adminGuard } from '../lib/require-user';` (neither `getSessionUser` nor `isAdmin` is used elsewhere in `admin.ts`). + +- [ ] **Step 3: Add the shared contracts** + +Append to `packages/shared/src/index.ts`: + +```ts +export const UserStatus = z.enum(['active', 'inactive']); +export type UserStatus = z.infer; + +export const CreateUserInput = z.object({ + email: z.string().email(), + name: z.string().trim().min(1), + password: z.string().min(8), + role: Role, +}); +export type CreateUserInput = z.infer; + +export const SetRoleInput = z.object({ role: Role }); +export type SetRoleInput = z.infer; + +export const SetPasswordInput = z.object({ password: z.string().min(8) }); +export type SetPasswordInput = z.infer; +``` + +Then extend the existing `AdminUser` schema (it is currently `id/email/name/role/created_at` and is unused in app code) by adding a `status` field. Change: + +```ts +export const AdminUser = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + role: Role, + created_at: z.string(), +}); +``` + +to: + +```ts +export const AdminUser = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + role: Role, + status: UserStatus, + created_at: z.string(), +}); +``` + +- [ ] **Step 4: Verify nothing broke** + +Run: `yarn workspace @solelog/api test admin && yarn workspace @solelog/api typecheck` +Expected: PASS — the existing `admin.test.ts` (gating + sessions) is green through the refactored guard; typecheck clean. + +- [ ] **Step 5: Format + commit** + +```bash +npx oxfmt apps/api/src/lib/require-user.ts apps/api/src/routes/admin.ts packages/shared/src/index.ts +git add apps/api/src/lib/require-user.ts apps/api/src/routes/admin.ts packages/shared/src/index.ts +git commit -m "refactor(api): extract adminGuard + add user-management contracts" +``` + +--- + +### Task 2: `admin-users.ts` router — list (moved + enriched) + create + +Create the new router with the enriched roster and user creation. Move `GET /api/admin/users` out of `admin.ts` in the same task so the path is never defined twice. + +**Files:** +- Create: `apps/api/src/routes/admin-users.ts` +- Modify: `apps/api/src/routes/admin.ts` (remove the old `GET /api/admin/users` block) +- Modify: `apps/api/src/app.ts` (mount the new router) +- Test: `apps/api/test/admin-users.test.ts` (new) + +**Interfaces:** +- Consumes: `adminGuard` (Task 1); `CreateUserInput`, `AdminUser` (Task 1). +- Produces: `adminUsersRoutes` (Hono) and the module-local `toListItem(row)` mapper + `GET`/`POST /api/admin/users` (consumed by Tasks 3-6). + +- [ ] **Step 1: Write the failing test** + +Create `apps/api/test/admin-users.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import type { Hono } from 'hono'; +import { createApp } from '../src/app'; +import { authToken, bearer } from './helpers'; + +const PASSWORD = 'sterk-wachtwoord-123'; + +async function signInStatus(app: Hono, email: string, password: string): Promise { + const res = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return res.status; +} + +describe('GET /api/admin/users', () => { + it('401s without a token, 403s for a worker', async () => { + const app = createApp(); + expect((await app.request('/api/admin/users')).status).toBe(401); + const workerTok = await authToken(app, 'users-list-worker@example.com'); + expect((await app.request('/api/admin/users', { headers: bearer(workerTok) })).status).toBe(403); + }); + + it('returns each user with role and status', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-list-admin@example.com', 'admin'); + await authToken(app, 'users-list-w@example.com'); // a worker + + const res = await app.request('/api/admin/users', { headers: bearer(adminTok) }); + expect(res.status).toBe(200); + const users = await res.json(); + const admin = users.find((u: { email: string }) => u.email === 'users-list-admin@example.com'); + const worker = users.find((u: { email: string }) => u.email === 'users-list-w@example.com'); + expect(admin.role).toBe('admin'); + expect(admin.status).toBe('active'); + expect(worker.role).toBe('worker'); + expect(worker.status).toBe('active'); + }); +}); + +describe('POST /api/admin/users', () => { + it('creates a user who can then sign in', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-create-admin@example.com', 'admin'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'created-worker@example.com', + name: 'Nieuwe Werker', + password: PASSWORD, + role: 'worker', + }), + }); + expect(res.status).toBe(200); + const created = await res.json(); + expect(created.email).toBe('created-worker@example.com'); + expect(created.role).toBe('worker'); + expect(created.status).toBe('active'); + expect(await signInStatus(app, 'created-worker@example.com', PASSWORD)).toBe(200); + }); + + it('rejects a duplicate email with 409', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-dup-admin@example.com', 'admin'); + const body = JSON.stringify({ + email: 'dup@example.com', + name: 'Dup', + password: PASSWORD, + role: 'worker', + }); + expect( + (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) + .status, + ).toBe(200); + expect( + (await app.request('/api/admin/users', { method: 'POST', headers: bearer(adminTok), body })) + .status, + ).toBe(409); + }); + + it('rejects a too-short password with 400', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'users-shortpw-admin@example.com', 'admin'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ email: 'short@example.com', name: 'S', password: 'short', role: 'worker' }), + }); + expect(res.status).toBe(400); + }); + + it('403s for a worker', async () => { + const app = createApp(); + const workerTok = await authToken(app, 'users-create-worker@example.com'); + const res = await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(workerTok), + body: JSON.stringify({ email: 'x@example.com', name: 'X', password: PASSWORD, role: 'worker' }), + }); + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: FAIL — `/api/admin/users` is now unmounted (we remove it from `admin.ts` next) / new POST returns 404. + +- [ ] **Step 3: Create the router** + +Create `apps/api/src/routes/admin-users.ts`: + +```ts +import { Hono } from 'hono'; +import { asc, eq } from 'drizzle-orm'; +import { CreateUserInput, type AdminUser, type Role } from '@solelog/shared'; +import { db } from '../db/client'; +import { session, user } from '../db/schema'; +import { auth } from '../auth'; +import { adminGuard } from '../lib/require-user'; + +export const adminUsersRoutes = new Hono(); + +adminUsersRoutes.use('/api/admin/users', adminGuard); +adminUsersRoutes.use('/api/admin/users/*', adminGuard); + +type UserRow = typeof user.$inferSelect; + +// Map a user row to the public list shape. +export function toListItem(row: UserRow): AdminUser { + return { + id: row.id, + email: row.email, + name: row.name, + role: (row.role ?? 'worker') as Role, + status: row.banned ? 'inactive' : 'active', + created_at: new Date(row.createdAt).toISOString(), + }; +} + +// Enriched roster (replaces the old id/name/email version from admin.ts). +adminUsersRoutes.get('/api/admin/users', async (c) => { + const rows = await db.select().from(user).orderBy(asc(user.name)); + return c.json(rows.map(toListItem)); +}); + +// Create a user. role/password validated by zod; password hashing via better-auth. +adminUsersRoutes.post('/api/admin/users', async (c) => { + const parsed = CreateUserInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + const d = parsed.data; + + // better-auth types role as its built-in set; our 'worker' role is valid at runtime (same cast as seed.ts). + const createUser = auth.api.createUser as (args: { + body: { email: string; password: string; name: string; role: 'worker' | 'admin' }; + }) => Promise; + + try { + await createUser({ body: { email: d.email, password: d.password, name: d.name, role: d.role } }); + } catch { + // Inputs are pre-validated, so the realistic failure is a duplicate email. + return c.json({ error: 'E-mailadres bestaat al.' }, 409); + } + + const [row] = await db.select().from(user).where(eq(user.email, d.email)); + return c.json(toListItem(row)); +}); +``` + +- [ ] **Step 4: Remove the old roster from `admin.ts`** + +In `apps/api/src/routes/admin.ts`, delete the entire `GET /api/admin/users` handler (the block with the comment "Roster for the create-form worker picker…" through its closing `});`). Leave `baseSelect`, the session routes, report, and export untouched. + +- [ ] **Step 5: Mount the router** + +In `apps/api/src/app.ts`, import and mount it after `adminRoutes`: + +```ts +import { adminUsersRoutes } from './routes/admin-users'; +``` + +```ts + app.route('/', adminRoutes); + app.route('/', adminUsersRoutes); +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: PASS — all list + create cases green. + +- [ ] **Step 7: Full API suite (regression: pickers still work), typecheck, format, commit** + +```bash +yarn workspace @solelog/api test +yarn workspace @solelog/api typecheck +npx oxfmt apps/api/src/routes/admin-users.ts apps/api/src/routes/admin.ts apps/api/src/app.ts apps/api/test/admin-users.test.ts +git add apps/api/src/routes/admin-users.ts apps/api/src/routes/admin.ts apps/api/src/app.ts apps/api/test/admin-users.test.ts +git commit -m "feat(api): admin-users router with enriched roster + user creation" +``` + +--- + +### Task 3: Set role + self-demote / last-admin guards + +**Files:** +- Modify: `apps/api/src/routes/admin-users.ts` (add the role route + guard helper) +- Test: `apps/api/test/admin-users.test.ts` (add a `role` describe block) + +**Interfaces:** +- Consumes: `toListItem` (Task 2); `SetRoleInput` (Task 1); `getSessionUser` (`../lib/require-user`). +- Produces: `activeAdminsExcluding(id)` helper (consumed by Task 4). + +- [ ] **Step 1: Add the failing tests** + +Append to `apps/api/test/admin-users.test.ts`. First add this import at the top of the file (alongside the existing imports): + +```ts +import { db } from '../src/db/client'; +import { user } from '../src/db/schema'; +import { eq } from 'drizzle-orm'; +``` + +Then add: + +```ts +async function userIdByEmail(email: string): Promise { + const [row] = await db.select().from(user).where(eq(user.email, email)); + return row.id; +} + +describe('POST /api/admin/users/:id/role', () => { + it('promotes a worker to admin and back', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'role-admin@example.com', 'admin'); + await authToken(app, 'role-target@example.com'); // worker + const id = await userIdByEmail('role-target@example.com'); + + const up = await app.request(`/api/admin/users/${id}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'admin' }), + }); + expect(up.status).toBe(200); + expect((await up.json()).role).toBe('admin'); + + const down = await app.request(`/api/admin/users/${id}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'worker' }), + }); + expect((await down.json()).role).toBe('worker'); + }); + + it('refuses self-demotion', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'role-self@example.com', 'admin'); + // a second admin so the last-admin guard is not what trips first + await authToken(app, 'role-self-other-admin@example.com', 'admin'); + const id = await userIdByEmail('role-self@example.com'); + const res = await app.request(`/api/admin/users/${id}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'worker' }), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('degraderen'); + }); + + it('refuses demoting the last active admin', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'role-last-admin@example.com', 'admin'); + // Promote a worker, then that worker is the only *other* admin; demote them while the caller + // is also admin — but to hit the last-admin path, demote the caller's only peer after demoting caller is blocked. + // Simplest: single admin scenario — create a second admin, demote them (ok), then try to demote remaining peer. + await authToken(app, 'role-peer@example.com', 'admin'); + const peerId = await userIdByEmail('role-peer@example.com'); + // Demote the peer: now caller is the only admin. Allowed (caller remains). + await app.request(`/api/admin/users/${peerId}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'worker' }), + }); + // Re-promote peer, then demote caller-equivalent is blocked by self-guard; instead verify the + // invariant directly: ban the caller is self-blocked, so assert the helper via a constructed case. + // Here we assert: with only the caller as admin, demoting *any other* admin is impossible because none exist, + // so we validate the guard by promoting peer again and confirming a non-self demote still leaves >=1 admin (caller). + await app.request(`/api/admin/users/${peerId}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'admin' }), + }); + const res = await app.request(`/api/admin/users/${peerId}/role`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ role: 'worker' }), + }); + // caller is still admin, so demoting the peer is allowed (>=1 admin remains). + expect(res.status).toBe(200); + }); +}); +``` + +Note: because the acting admin always remains an admin, the *non-self* last-admin path returns 200; the genuine last-admin protection is exercised through the self-demote guard above and the deactivate tests in Task 4. The helper is still implemented and used so the invariant holds if the self-guard is ever bypassed. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: FAIL — the role route returns 404. + +- [ ] **Step 3: Implement the role route + helper** + +In `apps/api/src/routes/admin-users.ts`, add `getSessionUser` to the require-user import: + +```ts +import { adminGuard, getSessionUser } from '../lib/require-user'; +``` + +Add `SetRoleInput` to the shared import: + +```ts +import { CreateUserInput, SetRoleInput, type AdminUser, type Role } from '@solelog/shared'; +``` + +Add the helper + route (after the create route): + +```ts +// Count admins that are active (not banned), excluding one user id. +export async function activeAdminsExcluding(excludeId: string): Promise { + const admins = await db.select({ id: user.id, banned: user.banned }).from(user).where(eq(user.role, 'admin')); + return admins.filter((a) => !a.banned && a.id !== excludeId).length; +} + +adminUsersRoutes.post('/api/admin/users/:id/role', async (c) => { + const id = c.req.param('id'); + const parsed = SetRoleInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + const newRole = parsed.data.role; + + const caller = await getSessionUser(c); + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + if (id === caller?.id && newRole !== 'admin') { + return c.json({ error: 'Je kunt jezelf niet degraderen.' }, 400); + } + if (target.role === 'admin' && newRole !== 'admin') { + if ((await activeAdminsExcluding(id)) === 0) { + return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400); + } + } + + await db.update(user).set({ role: newRole }).where(eq(user.id, id)); + const [updated] = await db.select().from(user).where(eq(user.id, id)); + return c.json(toListItem(updated)); +}); +``` + +Note `eq`/`user`/`db` are already imported (eq from Task-2 imports; `db`/`user` from Task 2). The Task-3 test file adds its own `eq`/`db`/`user` imports for the test helper. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: PASS. + +- [ ] **Step 5: Typecheck, format, commit** + +```bash +yarn workspace @solelog/api typecheck +npx oxfmt apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git add apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git commit -m "feat(api): set user role with self-demote + last-admin guards" +``` + +--- + +### Task 4: Deactivate / reactivate + guards + +**Files:** +- Modify: `apps/api/src/routes/admin-users.ts` (add deactivate + reactivate routes) +- Test: `apps/api/test/admin-users.test.ts` (add a `deactivate/reactivate` describe block) + +**Interfaces:** +- Consumes: `toListItem`, `activeAdminsExcluding` (Tasks 2-3); `getSessionUser`; `session` table (Task 2 import). + +- [ ] **Step 1: Add the failing tests** + +Append to `apps/api/test/admin-users.test.ts`: + +```ts +describe('deactivate / reactivate', () => { + async function signIn(app: Hono, email: string, password: string): Promise { + const res = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return res.status; + } + + it('blocks sign-in after deactivate and restores it after reactivate', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-admin@example.com', 'admin'); + // create a worker we control the password of + await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'deact-target@example.com', + name: 'Deact', + password: 'sterk-wachtwoord-123', + role: 'worker', + }), + }); + const id = await userIdByEmail('deact-target@example.com'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); + + const off = await app.request(`/api/admin/users/${id}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(off.status).toBe(200); + expect((await off.json()).status).toBe('inactive'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).not.toBe(200); + + const on = await app.request(`/api/admin/users/${id}/reactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect((await on.json()).status).toBe('active'); + expect(await signIn(app, 'deact-target@example.com', 'sterk-wachtwoord-123')).toBe(200); + }); + + it('refuses self-deactivation', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-self@example.com', 'admin'); + await authToken(app, 'deact-self-other@example.com', 'admin'); // a second admin + const id = await userIdByEmail('deact-self@example.com'); + const res = await app.request(`/api/admin/users/${id}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toContain('deactiveren'); + }); + + it('refuses deactivating the last active admin', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-last-admin@example.com', 'admin'); + // promote a peer, deactivate caller is self-blocked; deactivate the peer is allowed (caller remains). + await authToken(app, 'deact-peer@example.com', 'admin'); + const peerId = await userIdByEmail('deact-peer@example.com'); + const res = await app.request(`/api/admin/users/${peerId}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(res.status).toBe(200); // caller still admin → allowed + // Now the peer is inactive; the caller is the only active admin. Deactivating self is blocked: + const callerId = await userIdByEmail('deact-last-admin@example.com'); + const selfRes = await app.request(`/api/admin/users/${callerId}/deactivate`, { + method: 'POST', + headers: bearer(adminTok), + }); + expect(selfRes.status).toBe(400); // self-guard (which also preserves the last admin) + }); + + it('404s on an unknown id and 403s for a worker', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'deact-404-admin@example.com', 'admin'); + expect( + (await app.request('/api/admin/users/nope/deactivate', { method: 'POST', headers: bearer(adminTok) })) + .status, + ).toBe(404); + const workerTok = await authToken(app, 'deact-worker@example.com'); + const id = await userIdByEmail('deact-worker@example.com'); + expect( + (await app.request(`/api/admin/users/${id}/deactivate`, { method: 'POST', headers: bearer(workerTok) })) + .status, + ).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: FAIL — deactivate/reactivate routes return 404. + +- [ ] **Step 3: Implement deactivate + reactivate** + +In `apps/api/src/routes/admin-users.ts`, add after the role route: + +```ts +adminUsersRoutes.post('/api/admin/users/:id/deactivate', async (c) => { + const id = c.req.param('id'); + const caller = await getSessionUser(c); + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + if (id === caller?.id) return c.json({ error: 'Je kunt jezelf niet deactiveren.' }, 400); + if (target.role === 'admin' && (await activeAdminsExcluding(id)) === 0) { + return c.json({ error: 'Er moet minstens één actieve beheerder blijven.' }, 400); + } + + await db.update(user).set({ banned: true, banReason: null, banExpires: null }).where(eq(user.id, id)); + await db.delete(session).where(eq(session.userId, id)); // kill any live token + const [updated] = await db.select().from(user).where(eq(user.id, id)); + return c.json(toListItem(updated)); +}); + +adminUsersRoutes.post('/api/admin/users/:id/reactivate', async (c) => { + const id = c.req.param('id'); + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + await db + .update(user) + .set({ banned: false, banReason: null, banExpires: null }) + .where(eq(user.id, id)); + const [updated] = await db.select().from(user).where(eq(user.id, id)); + return c.json(toListItem(updated)); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: PASS. + +- [ ] **Step 5: Typecheck, format, commit** + +```bash +yarn workspace @solelog/api typecheck +npx oxfmt apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git add apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git commit -m "feat(api): deactivate/reactivate users (ban + revoke sessions) with guards" +``` + +--- + +### Task 5: Reset password + +**Files:** +- Modify: `apps/api/src/routes/admin-users.ts` (add the password route) +- Test: `apps/api/test/admin-users.test.ts` (add a `password` describe block) + +**Interfaces:** +- Consumes: `SetPasswordInput` (Task 1); `auth` (Task 2 import). + +- [ ] **Step 1: Add the failing tests** + +Append to `apps/api/test/admin-users.test.ts`: + +```ts +describe('POST /api/admin/users/:id/password', () => { + async function signIn(app: Hono, email: string, password: string): Promise { + const res = await app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return res.status; + } + + it('sets a new password: new works, old fails', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'pw-admin@example.com', 'admin'); + await app.request('/api/admin/users', { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ + email: 'pw-target@example.com', + name: 'Pw', + password: 'old-wachtwoord-123', + role: 'worker', + }), + }); + const id = await userIdByEmail('pw-target@example.com'); + + const res = await app.request(`/api/admin/users/${id}/password`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ password: 'new-wachtwoord-456' }), + }); + expect(res.status).toBe(200); + expect(await signIn(app, 'pw-target@example.com', 'new-wachtwoord-456')).toBe(200); + expect(await signIn(app, 'pw-target@example.com', 'old-wachtwoord-123')).not.toBe(200); + }); + + it('rejects a too-short password with 400 and 403s for a worker', async () => { + const app = createApp(); + const adminTok = await authToken(app, 'pw-short-admin@example.com', 'admin'); + await authToken(app, 'pw-worker@example.com'); + const id = await userIdByEmail('pw-worker@example.com'); + expect( + (await app.request(`/api/admin/users/${id}/password`, { + method: 'POST', + headers: bearer(adminTok), + body: JSON.stringify({ password: 'short' }), + })).status, + ).toBe(400); + const workerTok = await authToken(app, 'pw-worker2@example.com'); + expect( + (await app.request(`/api/admin/users/${id}/password`, { + method: 'POST', + headers: bearer(workerTok), + body: JSON.stringify({ password: 'long-enough-123' }), + })).status, + ).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: FAIL — the password route returns 404. + +- [ ] **Step 3: Implement the password route** + +In `apps/api/src/routes/admin-users.ts`, add `SetPasswordInput` to the shared import: + +```ts +import { CreateUserInput, SetPasswordInput, SetRoleInput, type AdminUser, type Role } from '@solelog/shared'; +``` + +Add the route. **Verify** `auth.api.setUserPassword`'s exact shape against the installed types before finalizing; the expected shape is `{ body: { userId, newPassword }, headers }`: + +```ts +adminUsersRoutes.post('/api/admin/users/:id/password', async (c) => { + const id = c.req.param('id'); + const parsed = SetPasswordInput.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) return c.json({ error: 'Invalid input' }, 400); + + const [target] = await db.select().from(user).where(eq(user.id, id)); + if (!target) return c.json({ error: 'Gebruiker niet gevonden' }, 404); + + try { + await auth.api.setUserPassword({ + body: { userId: id, newPassword: parsed.data.password }, + headers: c.req.raw.headers, + }); + } catch { + return c.json({ error: 'Wachtwoord wijzigen mislukt.' }, 400); + } + return c.json({ success: true }); +}); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `yarn workspace @solelog/api test admin-users` +Expected: PASS. If `setUserPassword` is unavailable/different in the installed better-auth, adjust to the correct admin method (verify via `node_modules/better-auth/dist/plugins/admin/admin.d.mts`) — the test asserts the behavior, not the call shape. + +- [ ] **Step 5: Full API suite, typecheck, format, commit** + +```bash +yarn workspace @solelog/api test +yarn workspace @solelog/api typecheck +npx oxfmt apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git add apps/api/src/routes/admin-users.ts apps/api/test/admin-users.test.ts +git commit -m "feat(api): admin reset-password endpoint" +``` + +--- + +### Task 6: Gebruikers screen + API client + nav + +**Files:** +- Create: `apps/admin/src/api/users.ts` +- Create: `apps/admin/src/components/UserForm.tsx` +- Create: `apps/admin/src/screens/Users.tsx` +- Modify: `apps/admin/src/components/Sidebar.tsx` (move Gebruikers into nav, drop the soon block) +- Modify: `apps/admin/src/App.tsx` (add `/gebruikers` route) +- Modify: `apps/admin/src/styles.css` (user table + pill styles) +- Test: `apps/admin/src/screens/Users.test.tsx` + +**Interfaces:** +- Consumes: `AdminUser`, `CreateUserInput`, `Role` (`@solelog/shared`); `apiFetch` (`../lib/api`); `useMe` (`../api/me`). +- Produces: `useUsers`, `useCreateUser`, `useSetUserRole`, `useResetUserPassword`, `useDeactivateUser`, `useReactivateUser`. + +- [ ] **Step 1: Implement the API client (no test of its own — exercised by the screen test)** + +Create `apps/admin/src/api/users.ts`: + +```ts +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { AdminUser, CreateUserInput, Role } from '@solelog/shared'; +import { apiFetch } from '../lib/api'; + +export function useUsers() { + return useQuery({ + queryKey: ['admin', 'users'], + queryFn: () => apiFetch('/api/admin/users'), + }); +} + +function useUsersMutation(fn: (arg: T) => Promise) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }), + }); +} + +export function useCreateUser() { + return useUsersMutation((input: CreateUserInput) => + apiFetch('/api/admin/users', { method: 'POST', body: JSON.stringify(input) }), + ); +} + +export function useSetUserRole() { + return useUsersMutation(({ id, role }: { id: string; role: Role }) => + apiFetch(`/api/admin/users/${id}/role`, { + method: 'POST', + body: JSON.stringify({ role }), + }), + ); +} + +export function useResetUserPassword() { + return useUsersMutation(({ id, password }: { id: string; password: string }) => + apiFetch<{ success: true }>(`/api/admin/users/${id}/password`, { + method: 'POST', + body: JSON.stringify({ password }), + }), + ); +} + +export function useDeactivateUser() { + return useUsersMutation((id: string) => + apiFetch(`/api/admin/users/${id}/deactivate`, { method: 'POST' }), + ); +} + +export function useReactivateUser() { + return useUsersMutation((id: string) => + apiFetch(`/api/admin/users/${id}/reactivate`, { method: 'POST' }), + ); +} +``` + +- [ ] **Step 2: Write the failing screen test** + +Create `apps/admin/src/screens/Users.test.tsx`: + +```tsx +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AdminUser } from '@solelog/shared'; +import Users from './Users'; +import { apiFetch } from '../lib/api'; + +vi.mock('../lib/api', () => ({ apiFetch: vi.fn() })); +const mockApiFetch = vi.mocked(apiFetch); + +const USERS: AdminUser[] = [ + { id: 'me', name: 'Beheerder', email: 'admin@x', role: 'admin', status: 'active', created_at: new Date('2026-06-01T00:00:00Z').toISOString() }, + { id: 'u2', name: 'Jan', email: 'jan@x', role: 'worker', status: 'active', created_at: new Date('2026-06-02T00:00:00Z').toISOString() }, + { id: 'u3', name: 'An', email: 'an@x', role: 'worker', status: 'inactive', created_at: new Date('2026-06-03T00:00:00Z').toISOString() }, +]; + +function mockEndpoints() { + mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => { + if (path === '/api/admin/users' && (!init || init.method === undefined)) + return Promise.resolve(USERS as never); + if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never); + return Promise.resolve({} as never); + }); +} + +function renderUsers() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +describe('Users', () => { + beforeEach(() => mockApiFetch.mockReset()); + afterEach(() => vi.clearAllMocks()); + + it('renders a row per user with role and status', async () => { + mockEndpoints(); + renderUsers(); + expect(await screen.findByText('Jan')).toBeInTheDocument(); + expect(screen.getByText('An')).toBeInTheDocument(); + expect(screen.getAllByText('Werker').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('Inactief')).toBeInTheDocument(); + }); + + it('hides role/deactivate actions on the signed-in admin own row', async () => { + mockEndpoints(); + renderUsers(); + const adminRow = (await screen.findByText('Beheerder')).closest('tr') as HTMLElement; + expect(within(adminRow).getByText('jij')).toBeInTheDocument(); + expect(within(adminRow).queryByRole('button', { name: 'Deactiveer' })).not.toBeInTheDocument(); + }); + + it('+ Nieuwe gebruiker posts CreateUserInput', async () => { + mockEndpoints(); + renderUsers(); + await screen.findByText('Jan'); + await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' })); + await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw'); + await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl'); + await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-123'); + await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users', + expect.objectContaining({ method: 'POST' }), + ), + ); + }); + + it('Deactiveer on another user calls the deactivate endpoint', async () => { + mockEndpoints(); + renderUsers(); + const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement; + await userEvent.click(within(janRow).getByRole('button', { name: 'Deactiveer' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users/u2/deactivate', + expect.objectContaining({ method: 'POST' }), + ), + ); + }); +}); +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `yarn workspace @solelog/admin test Users` +Expected: FAIL — `./Users` not found. + +- [ ] **Step 4: Implement the create form** + +Create `apps/admin/src/components/UserForm.tsx`: + +```tsx +import { useState } from 'react'; +import type { CreateUserInput, Role } from '@solelog/shared'; + +export default function UserForm({ + onSubmit, + onCancel, + pending, + error, +}: { + onSubmit: (input: CreateUserInput) => void; + onCancel: () => void; + pending: boolean; + error: string | null; +}) { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('worker'); + const tooShort = password.length > 0 && password.length < 8; + + return ( +
{ + e.preventDefault(); + if (password.length < 8) return; + onSubmit({ name: name.trim(), email: email.trim(), password, role }); + }} + > + + + + {tooShort &&

Minstens 8 tekens.

} + + {error &&

{error}

} +
+ + +
+
+ ); +} +``` + +- [ ] **Step 5: Implement the screen** + +Create `apps/admin/src/screens/Users.tsx`: + +```tsx +import { useState } from 'react'; +import type { AdminUser, CreateUserInput } from '@solelog/shared'; +import { + useCreateUser, + useDeactivateUser, + useReactivateUser, + useResetUserPassword, + useSetUserRole, + useUsers, +} from '../api/users'; +import { useMe } from '../api/me'; +import UserForm from '../components/UserForm'; + +export default function Users() { + const usersQuery = useUsers(); + const meQuery = useMe(); + const myId = meQuery.data?.user.id; + + const createUser = useCreateUser(); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + function onCreate(input: CreateUserInput) { + setCreateError(null); + createUser.mutate(input, { + onSuccess: () => setCreating(false), + onError: () => setCreateError('E-mailadres bestaat al of ongeldig.'), + }); + } + + if (usersQuery.isLoading) { + return ( +
+

Laden…

+
+ ); + } + if (usersQuery.isError) { + return ( +
+

Kon gebruikers niet laden.

+
+ ); + } + + const users = usersQuery.data ?? []; + + return ( +
+
+

Gebruikers

+ +
+ + {creating && ( + setCreating(false)} + pending={createUser.isPending} + error={createError} + /> + )} + + + + + + + + + + + + + + {users.map((u) => ( + + ))} + +
NaamE-mailRolStatusAangemaaktActies
+
+ ); +} + +function UserRow({ user, isSelf }: { user: AdminUser; isSelf: boolean }) { + const setRole = useSetUserRole(); + const deactivate = useDeactivateUser(); + const reactivate = useReactivateUser(); + const resetPassword = useResetUserPassword(); + + const [resetting, setResetting] = useState(false); + const [pw, setPw] = useState(''); + const busy = + setRole.isPending || deactivate.isPending || reactivate.isPending || resetPassword.isPending; + + function onReset() { + if (pw.length < 8) return; + resetPassword.mutate( + { id: user.id, password: pw }, + { + onSuccess: () => { + setResetting(false); + setPw(''); + }, + }, + ); + } + + return ( + + + {user.name} {isSelf && jij} + + {user.email} + + + {user.role === 'admin' ? 'Beheerder' : 'Werker'} + + + + + {user.status === 'active' ? 'Actief' : 'Inactief'} + + + {new Date(user.created_at).toLocaleDateString('nl-BE')} + + {!isSelf && ( + + )} + {resetting ? ( + + setPw(e.target.value)} + minLength={8} + /> + + + + ) : ( + + )} + {!isSelf && + (user.status === 'active' ? ( + + ) : ( + + ))} + + + ); +} +``` + +- [ ] **Step 6: Wire nav + route** + +In `apps/admin/src/components/Sidebar.tsx`, add Gebruikers to `navItems` and remove the now-empty soon block. Replace: + +```tsx +const navItems = [ + { to: '/', label: 'Live' }, + { to: '/handelingen', label: 'Handelingen' }, + { to: '/sessies', label: 'Sessies' }, + { to: '/rapporten', label: 'Rapporten' }, +] as const; + +// Sections planned for the final Phase 3b cycle — shown muted/disabled. +const soonItems = ['Gebruikers'] as const; +``` + +with: + +```tsx +const navItems = [ + { to: '/', label: 'Live' }, + { to: '/handelingen', label: 'Handelingen' }, + { to: '/sessies', label: 'Sessies' }, + { to: '/rapporten', label: 'Rapporten' }, + { to: '/gebruikers', label: 'Gebruikers' }, +] as const; +``` + +Then delete the JSX block that renders `soonItems` (the `
` containing the `soonItems.map(...)`), since `soonItems` no longer exists. + +In `apps/admin/src/App.tsx`, import and add the route: + +```tsx +import Users from './screens/Users'; +``` + +```tsx + } /> + } /> +``` + +- [ ] **Step 7: Add styles** + +Append to `apps/admin/src/styles.css`: + +```css +.users-table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; +} +.users-table th, +.users-table td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--border, #e4e4e7); + vertical-align: middle; +} +.users-actions { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.users-reset { + display: inline-flex; + gap: 0.3rem; + align-items: center; +} +.pill { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} +.pill-admin { + background: #ede9fe; + color: #6d28d9; +} +.pill-worker { + background: #e0f2fe; + color: #0369a1; +} +.pill-active { + background: #dcfce7; + color: #15803d; +} +.pill-inactive { + background: #fee2e2; + color: #b91c1c; +} +.user-self-badge { + font-size: 0.7rem; + color: var(--muted, #71717a); + font-style: italic; +} +.user-form { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: flex-end; + padding: 1rem; + background: var(--surface, #f4f4f5); + border-radius: 0.5rem; + margin-bottom: 1rem; +} +.user-form label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; +} +.user-form-actions { + display: flex; + gap: 0.5rem; +} +``` + +- [ ] **Step 8: Run the screen test** + +Run: `yarn workspace @solelog/admin test Users` +Expected: PASS — all four cases green. + +- [ ] **Step 9: Full admin suite, typecheck, build, format, commit** + +```bash +yarn workspace @solelog/admin test +yarn workspace @solelog/admin typecheck +yarn workspace @solelog/admin build +npx oxfmt apps/admin/src/api/users.ts apps/admin/src/components/UserForm.tsx apps/admin/src/screens/Users.tsx apps/admin/src/screens/Users.test.tsx apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx +git add apps/admin/src/api/users.ts apps/admin/src/components/UserForm.tsx apps/admin/src/screens/Users.tsx apps/admin/src/screens/Users.test.tsx apps/admin/src/components/Sidebar.tsx apps/admin/src/App.tsx apps/admin/src/styles.css +git commit -m "feat(admin): Gebruikers screen (list, create, role, password, deactivate)" +``` + +--- + +## Final verification (after all tasks) + +- [ ] `yarn workspace @solelog/api test` — all green (incl. `admin-users`, regression `admin`/`report`/`export`). +- [ ] `yarn workspace @solelog/admin test` — all green (incl. `Users`). +- [ ] `yarn workspace @solelog/api typecheck` && `yarn workspace @solelog/admin typecheck` — clean. +- [ ] `yarn workspace @solelog/admin build` — succeeds. +- [ ] `npx oxlint` — clean. +- [ ] `git log --oneline` shows six task commits. + +## Self-review notes (plan vs spec) + +- **Refactor (adminGuard):** Task 1. **Contracts:** Task 1 (`UserStatus`, `AdminUser.status`, `CreateUserInput`, `SetRoleInput`, `SetPasswordInput`). ✓ +- **List (moved+enriched):** Task 2. **Create (+dup 409, +short-pw 400):** Task 2. ✓ +- **Set role + self-demote + last-admin:** Task 3. ✓ +- **Deactivate (ban + revoke sessions) / reactivate + self-deactivate + last-admin:** Task 4. ✓ +- **Reset password (new works/old fails):** Task 5. ✓ +- **UI (Gebruikers list + pills, create form, role/password/deactivate row actions, own-row hide, nav move + route):** Task 6. ✓ +- **No migration / no hard delete / password floor 8 / admin-gated:** Global Constraints + enforced per task. ✓ +- **better-auth shape verification:** flagged in Global Constraints + Task 5 Step 4. ✓