# 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) => ( ))}
Naam E-mail Rol Status Aangemaakt Acties
); } 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. ✓