7.8 KiB
spec, created, updated, status
| spec | created | updated | status |
|---|---|---|---|
| docs/specs/auth/requirements/self-actions-guard.md | 2026-08-15 | 2026-08-15 | complete |
Implementation Plan: Self-Actions Guard in System Users
Overview
Settings > System > Users lets an admin change the role or status of any listed
account, including their own. The backend already rejects demoting or disabling
the last active admin (409 ErrLastAdmin), but the page shows the toggle
buttons as enabled, so the rejection surfaces only as a toast after the click.
The fix greys out exactly those buttons the backend would reject: on any row
that is an active admin, the role toggle ("Make member") and status toggle
("Disable") are disabled when no other active admin exists.
This is a UI-only change. The backend keeps its current last-admin guard verbatim; self-demotion and self-disable remain possible while another active admin exists, exactly as today.
Current behavior
- Backend:
ensureAnotherAdmininapps/backend/internal/auth/service_users.gorejects a change that would leave zero users withrole = adminandstatus = active(excluding the target).AdminSetRoleStatusapplies it to any target, self included; theupdateUserhandler documents that self-demotion/disable is allowed when another active admin remains. This behavior is correct and stays unchanged. - Frontend:
UserRowinapps/web/components/settings/system/users-table.tsxrenders the role and status toggle buttons for every row with no guard awareness. The user list (useUsersList) is the same data source the backend guard scans (ListUsers), so the disabled state can be derived from the rendered rows. - E2E: auth-enabled specs live in
apps/web/e2e/tests/auth/*.spec.ts, run in theauthPlaywright project (KANDEV_FEATURES_AUTH=truebackend restart; helpers ine2e/helpers/auth.ts). No functional spec exists for the Users page today (onlyauth-screenshots.spec.ts).
Frontend
apps/web/components/settings/system/users-table.tsx:- Derive
activeAdminCountfrom the loaded users:users.filter((u) => u.role === "admin" && u.status === "active").length, mirroringensureAnotherAdminexactly. - In
UsersTableList/UserRow, disable a row's role and status toggles when the row is an active admin andactiveAdminCount === 1(isLastActiveAdmin). This is precisely the set of actions the backend rejects today (demote or disable of the last active admin); every other button stays enabled. - No current-user identity is needed: the guard is target-based, not caller-based, and the own row is just an active-admin row.
- Keep existing labels and
data-testidattributes (users-table-toggle-role,users-table-toggle-status). Shadcn button already styles the disabled state (disabled:opacity-45 disabled:cursor-not-allowed). - No new user-facing copy; no i18n key changes.
- Derive
Tests
- No backend tests change: the backend is untouched.
- No frontend unit tests: the change is button state in component markup,
which this repo does not unit-test. The behavior is covered by the E2E
spec below plus the existing
TestAdminUserManagementbackend guard tests (which stay green and prove the guard still exists).
E2E
- New
apps/web/e2e/tests/auth/users-self-actions.spec.tsin theauthproject: serial describe,backend.restart({ KANDEV_FEATURES_AUTH: "true" })inbeforeAll, restart to baseline inafterAll(pattern fromauth-screenshots.spec.ts). - Setup admin + login via
e2e/helpers/auth.ts; create a member viaPOST /api/v1/userswith the admin context (context.requestshares the session cookie). - Assertions:
- Sole admin (fresh setup): own row (
[data-user-id="{adminId}"]) has both togglestoBeDisabled(); the member row has bothtoBeEnabled(). - Existing guard proof:
PATCH /api/v1/users/{self}with{ "role": "member" }as the sole admin returns 409ErrLastAdmin(backend unchanged). - Create a second active admin via the API, reload the page: the own row's
toggles are now
toBeEnabled()(guard passes; button state tracks it). - No-new-behavior proof:
PATCH /api/v1/users/{self}with{ "role": "member" }now returns 200 (self-demotion stays allowed when another active admin exists).
- Sole admin (fresh setup): own row (
- Run:
cd apps/web && pnpm e2e:run --project auth tests/auth/users-self-actions.spec.ts.
Implementation Waves And Parallel Candidates
Wave 1:
Wave 2:
Execution is sequential. Task 02 depends on the frontend change from Task 01.
Verification
Focused, per task:
cd "$(git rev-parse --show-toplevel)" && make typecheck
cd "$(git rev-parse --show-toplevel)/apps/backend" && go test ./internal/auth/... -run TestAdminUserManagement -count=1
cd apps/web && pnpm e2e:run --project auth tests/auth/users-self-actions.spec.ts
Final gate (per AGENTS.md): make fmt, then make typecheck test lint. E2E
is not part of make test; the auth-project spec runs via make test-e2e in
CI.
Mobile parity
The change adds a disabled attribute to two buttons in the shared
users-table.tsx component; layout, scrolling, navigation, and touch targets
are unchanged, and the state derivation is shared markup. Mobile coverage is
still provided: apps/web/e2e/tests/auth/mobile-users-self-actions.spec.ts
runs in the mobile-chrome project (Pixel 5) with the same per-file isolated
auth database and asserts the sole-admin own row is disabled while member and
disabled-admin rows stay enabled, plus re-enablement after a second active
admin. It is routed out of the desktop auth project via its testIgnore
(e2e/playwright.config.ts) so it runs exactly once, on the mobile viewport.
Recorded Results
- E2E:
cd apps/web && pnpm e2e:run --project auth tests/auth/users-self-actions.spec.ts— RED first (sole-admin toggle enabled, expected disabled), then 1 passed after the component change. make fmtpassed (unrelated pre-existing gofmt drift in four backend files was restored, not committed).make typecheckpassed.make lintpassed: golangci-lint 0 issues, eslint--max-warnings 0clean, 118 harness files passed, architecture lint passed.go test ./internal/auth/... -run TestAdminUserManagement -count=1passed (backend guard untouched).make testis not fully green in this environment for pre-existing reasons, all proven unrelated by stashing this change: harness-injectedKANDEV_VERSION=v0.88.0-dirtybreaks two launcher metadata tests; the host~/.profilereferences a missing/tmp/cargo/env(agentctl config test); two agentctl process-lifecycle tests are timing-sensitive under host load;lib/http-git-server.test.tsrequires a Docker bridge gateway (no Docker daemon on this host);test-scriptsneeds theunzipbinary (absent, no sudo to install). CI has none of these gaps.
Risks and limits
- Button state is a mirror of the last loaded user list, not a live
authorization decision. A stale list (an admin demoted or disabled in
another session since the fetch) can show an enabled button whose request
the backend rejects; the existing 409
ErrLastAdmintoast covers that path unchanged. - The frontend count must match
ensureAnotherAdmin's condition exactly (role = adminANDstatus = active, excluding the target row). Any drift between the mirror and the guard would show either a clickable-rejected button or a needlessly disabled one; the E2E asserts both directions (sole-admin disabled, two-admin enabled). - The
userssnapshot used for the count is the sameListUsersdata the backend guard scans, so the mirror and the guard evaluate the same set modulo request-time staleness.