95 lines
6.0 KiB
Markdown
95 lines
6.0 KiB
Markdown
# 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.
|