Skip to main content

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

RoutePurpose
/ops/usersFull 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 active or suspended.
  • 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

FieldNotes
NameFree text. Trimmed; an empty string clears the name.
EmailValidated client-side; must be unique. A collision with another user returns 409 Conflict ("Email already in use"), surfaced inline on the email field.
Platform roleSelect. 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.
StatusSelect: 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 superadmin reveals 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 to suspended or deactivated ("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 a SERIALIZABLE transaction (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.adminInitiatePasswordReset rejects non-active targets with 400 ("Cannot send a password reset to a non-active user"). The UI offers the action for active or suspended users, 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_initiated audit 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:

OperationAudit actionPayload
Edit-user form saveadmin.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_updatedSame diff shape. The edit form does not use this endpoint — it sends role changes through admin.user.updated.
Password reset initiationadmin.user.password_reset_initiatedUsed to enforce the 5/hour per-actor rate limit.
User removal (DELETE /admin/users/:id)admin.user.removedAnonymise (default) or hard-delete (?hard=true, non-prod only).
Impersonationimpersonation audit trailSurfaced 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.