# 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.