diff --git a/docs/roadmap.md b/docs/roadmap.md index d0c41fe..50d9bf8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -193,8 +193,14 @@ Each phase keeps the system working and is its own spec → plan → build cycle worker/type/activity filters, headline totals and breakdowns per worker/activity/type) backed by `GET /api/admin/report` (completed-only, JS aggregation), plus an all-users filtered CSV via `GET /api/admin/export` — a shared `buildSessionsCsv(rows, {includeWorker})` now serves both the - worker self-export and the admin export. **3b remaining:** user management - (better-auth `/api/auth/admin/*`). + worker self-export and the admin export. + **3b·3 implemented** (plan `docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md`): + the admin **Gebruikers** screen — list users (role + active/inactive status), create, set role, + reset password, deactivate/reactivate — backed by a new `/api/admin/users*` router (a shared + `adminGuard` middleware; `role`/`banned` as direct column updates, create + reset-password via + better-auth). Server-side lockout guards (no self-deactivate, no self-demote, last-active-admin + invariant), `adminGuard` also rejects banned admins, and deactivate revokes the user's sessions. + **Phase 3b and the Phase 3 admin panel are complete.** Activity management (add/edit/delete handelingen + their `insole_types`) was removed from the worker client in the Phase 2 follow-up because it is admin-only; it must be **ported here**. The backend already exists (`/api/activities` writes are admin-gated; `useActivities`/the legacy diff --git a/docs/sessions/2026-06-24-phase-3b3-user-management.md b/docs/sessions/2026-06-24-phase-3b3-user-management.md new file mode 100644 index 0000000..1876dbb --- /dev/null +++ b/docs/sessions/2026-06-24-phase-3b3-user-management.md @@ -0,0 +1,94 @@ +# Session: 2026-06-24 — Phase 3b·3 (User management — Gebruikers) + +## Goal + +Let an admin manage workplace logins: list users 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, and with server-side guards that make admin lockout impossible. Third and final +Phase 3b cycle; completes the Phase 3 admin panel. +Spec: `docs/superpowers/specs/2026-06-24-phase-3b3-user-management-design.md`; +plan: `docs/superpowers/plans/2026-06-24-phase-3b3-user-management.md`. +Tracked as Plane epic **SL-54** with tasks **SL-55…SL-60**. + +## Scope decisions (brainstorming) + +- **Operations:** create, set role (worker ↔ admin), reset password, deactivate/reactivate. + **No hard delete** — the `work_sessions` FK cascades, so deletion would wipe logged history; + deactivation (better-auth `banned`) keeps the data and blocks sign-in. +- **Lockout guards (server-side):** no self-deactivate, no self-demote, last-active-admin invariant. +- **Password floor:** 8 chars (better-auth default) on create + reset. No email reset flow (no mailer). +- **No DB migration** — `role`, `banned`, `banReason`, `banExpires` already exist on `user`. + +## Work done + +Built via a 6-task TDD **Workflow** (`wptwrno4a`, ultracode) — one commit per task, sequential +(dependent, shared tree) — followed by a two-lens adversarial security review, then a hardening pass: + +- **Task 1 — adminGuard + contracts** (`a32406d`). Extracted the inline `/api/admin/*` gate into a + reusable `adminGuard` middleware in `lib/require-user.ts` (used by both admin routers). Added + `UserStatus`, `status` on `AdminUser`, `CreateUserInput`, `SetRoleInput`, `SetPasswordInput` to + `@solelog/shared`. +- **Task 2 — admin-users router: list + create** (`2b4b633`). New `routes/admin-users.ts`. Enriched + `GET /api/admin/users` (id/email/name/role/status/created_at), **moved out of `admin.ts`** so the + path is defined once; the report/session pickers keep working (they read id/name). `POST + /api/admin/users` via `auth.api.createUser` (hashing), duplicate email → 409. Mounted in `app.ts`. +- **Task 3 — set role + guards** (`88b7773`). `POST /:id/role` as a direct column update, with the + `activeAdminsExcluding` helper, self-demote guard, and last-admin guard. +- **Task 4 — deactivate/reactivate** (`40977f8`). `POST /:id/deactivate` sets `banned=true` **and + deletes the user's `session` rows** (kills live bearer tokens); `POST /:id/reactivate` clears it. + Self-deactivate + last-admin guards. +- **Task 5 — reset password** (`4b80898`). `POST /:id/password` via `auth.api.setUserPassword` + (shape verified against the installed better-auth types: `{ userId, newPassword }`). +- **Task 6 — Gebruikers UI** (`0f42960`). `api/users.ts` (`useUsers` + 5 mutations), `UserForm.tsx` + (create), `Users.tsx` (list with role/status pills, own-row "jij" badge hiding role/deactivate, + row actions for role toggle / inline password reset / deactivate-reactivate). Sidebar moves + Gebruikers into nav and drops the now-empty "Binnenkort" block; `/gebruikers` route added. + +## Adversarial security review (two lenses, read-only) + +Both lenses returned **no exploitable holes**: `lastAdminProtected`, `gatingConfirmed`, +`deactivateBlocksSignin` all true. Key confirmations: no reachable sequence reaches zero active +admins (the caller is always an active admin ≠ target, so `activeAdminsExcluding(target) ≥ 1`, and +the self-guards stop the caller removing their own admin/active status); deactivate genuinely blocks +sign-in (better-auth throws `BANNED_USER` in `session.create.before`, `banExpires=null` prevents +auto-unban) **and** the session-row deletion is the load-bearing half that revokes live tokens +(core session validation does not re-check `banned`); every `/api/admin/users*` route is gated. + +It surfaced three real, fixable issues, addressed in a hardening pass (`b9aa24c`): + +1. **Test quality** — the two "last active admin" tests asserted `200`/the self-guard, never the + last-admin branch (which is unreachable over HTTP). Renamed them honestly and added a **direct + unit test of `activeAdminsExcluding`** (covers the `!banned` filter + exclusion). +2. **500 on mixed-case create** — create re-selected by original-case email, but better-auth + lowercases on store → `toListItem(undefined)`. Now re-selects on `email.toLowerCase()`; added a + mixed-case create test. +3. **Defense-in-depth** — `adminGuard` checked role only. It now also **rejects banned admins** + (so a banned admin holding a live token is refused even if their session somehow survives); + added a banned-admin-guard test. + +## Verification (independent of the workflow's self-reports) + +- `git log` — six task commits `a32406d → 0f42960` + hardening `b9aa24c`; **clean tree**. +- `yarn workspace @solelog/api test` — **103 passed** (15 files), incl. `admin-users` and the + regression `admin`/`report`/`export`/`csv` suites; `typecheck` clean. +- `yarn workspace @solelog/admin test` — **50 passed** (11 files), incl. `Users`; `typecheck` + clean; `build` succeeds (vite, 94 modules). +- `npx oxlint` — clean (exit 0). + +## Outcome + +Phase 3b·3 is implemented, hardened, and green across both touched workspaces. An admin can list +users with role + status, create workers/admins (who can then sign in), flip roles, reset passwords +(new works / old fails), and deactivate/reactivate accounts — with sign-in genuinely blocked on +deactivation and live tokens revoked, and with lockout made impossible by the self/last-admin guards +plus the banned-admin gate. No DB migration; no hard delete. Plane SL-54 + SL-55…SL-60 all Done. +**Phase 3b and the Phase 3 admin panel are complete.** + +`origin/main` was advanced by the maintainer between cycles; after this cycle `main` is **9 ahead** +(the whole 3b·3 batch). A single `git push origin main` from the maintainer's terminal ships it +(and triggers Gitea CI). + +## Next + +Phase 3 is done. Per the roadmap, **Phase 4 — Workbench scanning** (QR at the bench → pre-fill the +session, with manual fallback) and **Phase 5 — Polish & deploy** remain.