Feature: SuperAdmin User Administration (/ops/users)
Last updated: 2026-06-24 (documents the edit-user form shipped in #1299, closes #1240; fills the docs gap tracked in #1344)
/ops/users is the SuperAdmin surface for managing every platform user
across all orgs and roles. The list page (/ops/users) is a searchable,
role-filterable table of all users; clicking a row opens the user detail
page (/ops/users/[id]), which is where the edit-user form, password-reset
trigger, and impersonation entry point live.
This page is SuperAdmin only. AMs who navigate here are redirected to
/ops/clients (see the route table in
USER_OPERATIONS_MANUAL.md §6.2). All
mutating endpoints below are guarded by JwtAuthGuard + PlatformRolesGuard
with @PlatformRoles("superadmin").
Surfaces
| Route | Purpose |
|---|---|
/ops/users | Full user table — Name, Email, Role badge, Status badge, Org. Role filter dropdown + search by name/email. |
/ops/users/[id] | User detail: Identity, Lifecycle (incl. audit trail), Memberships, Recent Activity, and the Actions dropdown (Edit / Reset password / Impersonate). |
The detail page's actions are built in buildActions()
(frontend/src/app/ops/users/[id]/page.tsx):
- Edit — always available.
- Reset password — shown when the user's status is
activeorsuspended. - Impersonate — shown for every user except SuperAdmins.
Edit-user form
The Edit action opens a modal backed by adminUpdateUser() →
PATCH /admin/users/:id (AuthController.updateUser,
AdminUpdateUserDto). The form only sends fields that actually changed
(diff-on-submit), and the Save button stays disabled until at least one
field changes and the email is syntactically valid.
Editable fields
| Field | Notes |
|---|---|
| Name | Free text. Trimmed; an empty string clears the name. |
Validated client-side; must be unique. A collision with another user returns 409 Conflict ("Email already in use"), surfaced inline on the email field. | |
| Platform role | Select. The UI dropdown exposes superadmin, account_manager, expert, ai_operator, org_admin, org_member. The backend AdminUpdateUserDto / ADMIN_SETTABLE_ROLES also still validate client_admin and client_member, but those are legacy/dead platform-role values (never assigned, per #2847) — the Edit dropdown intentionally excludes them and they should not be set via the API. |
| Status | Select: active, suspended, deactivated. If the user is currently in a status outside this set, it appears as a read-only "(current)" option. |
The form does not set passwords. Password changes go through the
password-reset flow (or the dedicated
PATCH /admin/users/:id/password endpoint), never the edit form.
Role-change semantics & guard rails
Role and status changes are not free-form — both the form and the backend enforce the following invariants:
- SuperAdmin grant requires confirmation. Promoting a non-SuperAdmin to
superadminreveals a confirmation box; the SuperAdmin must type the target user's email exactly before Save is enabled. - No self-demotion. You cannot change your own platform role
(
"Cannot change your own SuperAdmin role"), and you cannot set your own status tosuspendedordeactivated("Cannot suspend or deactivate your own account"). - Last-SuperAdmin protection. Demoting or disabling (
suspended/deactivated) the only remaining active SuperAdmin is rejected ("Cannot demote the only remaining SuperAdmin"/"Cannot suspend or deactivate the only remaining SuperAdmin"). The count is checked inside aSERIALIZABLEtransaction (runAdminUserMutation) so two concurrent demotions can't race past the guard.
Session revocation
Changing email, platform role, or setting status to suspended /
deactivated revokes the target user's active sessions. The backend bumps
user.passwordChangedAt, which invalidates outstanding JWTs and forces an OTP
re-login on the target's next request. Because this is a destructive
side-effect, the form interposes a "Revoke active sessions?" confirmation
modal before submitting whenever one of those fields changed. (Name-only edits
skip the confirmation and do not touch sessions.)
Password reset
The Reset password action calls adminInitiatePasswordReset() →
POST /admin/users/:id/password-reset (HTTP 204). It generates a 6-digit OTP
and emails it to the target user; the acting SuperAdmin never sees the code
— the user completes the reset themselves at /auth/reset-password. Notable
behavior:
- Active users only (backend).
AuthService.adminInitiatePasswordResetrejects non-activetargets with 400 ("Cannot send a password reset to a non-active user"). The UI offers the action foractiveorsuspendedusers, so a reset attempt on a suspended user surfaces that 400. - Rate limited. Max 5 initiations per actor per hour, keyed by actor (not
IP); the 6th returns 429. The limit is best-effort: the count of prior
admin.user.password_reset_initiatedaudit rows is read at the top of the method and the new audit row is written near the end (after the email is sent), without a surrounding transaction or lock — so a burst of simultaneous requests can each observe fewer than 5 prior rows and slip through before any row is written. It is not a hard concurrency barrier.
Impersonation cross-reference
Impersonation is launched from this page's Impersonate action but is its own
audited flow — see
USER_OPERATIONS_MANUAL.md §7 and the
/ops/impersonation audit trail for the full treatment. Key points relevant to
/ops/users:
- A reason is required before impersonation can start; it is recorded for audit.
- SuperAdmins cannot be impersonated (
"Cannot impersonate a Super Admin."), which is why the action is hidden for SuperAdmin rows. - Starting impersonation (
impersonateUser()→POST /admin/impersonate/:id) opens a new tab logged in as the target via/impersonate/start. Every action taken while impersonating is audited.
Audit trail
Admin user-administration mutations write an audit row via
auditAdminUserMutation (best-effort — audit failure never rolls back the
business write, per the platform's layered Data Consistency Contract). The
table below covers the SuperAdmin user-admin endpoints; note that not all map
to a button in this page's Actions dropdown (which offers only Edit, Reset
password, and Impersonate) — admin.user.role_updated and admin.user.removed
come from sibling endpoints (PATCH …/role, DELETE …) rather than the
detail-page UI:
| Operation | Audit action | Payload |
|---|---|---|
| Edit-user form save | admin.user.updated | { actorId, targetUserId, changes } — changes is a field-level { from, to } diff (incl. a passwordChangedAt: "revoked" entry when sessions are revoked). |
Dedicated role endpoint (PATCH /admin/users/:id/role) | admin.user.role_updated | Same diff shape. The edit form does not use this endpoint — it sends role changes through admin.user.updated. |
| Password reset initiation | admin.user.password_reset_initiated | Used to enforce the 5/hour per-actor rate limit. |
User removal (DELETE /admin/users/:id) | admin.user.removed | Anonymise (default) or hard-delete (?hard=true, non-prod only). |
| Impersonation | impersonation audit trail | Surfaced at /ops/impersonation. |
The detail page's Lifecycle → Audit Trail and Recent Activity sections
are sourced from getAdminAuditLog({ actorUserId: userId }), which filters on
al.userId — the audit actor. So these sections show actions this user
performed, not mutations performed against them. Note the consequence:
admin user mutations store the acting SuperAdmin as userId and the edited
user as resourceId, so when Alice edits Bob, that admin.user.updated row
appears under Alice's activity, not on Bob's detail page. To see lifecycle
changes made to a user (by resourceId/target), use the full platform-wide
log at /ops/audit.