Feature: Remove a user, guarded by the member's role (backend route and SPA action) As an owner, or a person whose role holds "Remove users" I want to take someone's access away So that a removal never hits a member whose role changed since I looked, and never exceeds my own power Background: Given the spec of record is docs/design/screens/roles.md group 5 (row menu "Remove user", owner row "Remove user (disabled)"), group 12 (toast "User not removed") and the tables "HTTP status to copy" and "No-escalation feedback" And the permission is users.remove ("Remove users", an on/off toggle of the catalogue, internal/domain/role KeyUsersRemove), already known to the catalogue and to the role editor And the guards mirror ChangeUserRole and TransferOwnership (internal/infrastructure/useCases): ensureCoveredBy for the caller's power, role.ErrOwnRole, role.ErrRoleLocked, user.ErrRoleChanged, user.ErrUserNotFound And a refusal is plain text: user_role_changed, own_role, role_locked, escalation (X-Escalation-Key), missing_permission (X-Missing-Permission), user_not_found, invalid_role And the dialog copy and the success toast copy of a removal are not in the design (design gap G1, open question Q-RM1): they are specced as behaviour, never asserted as text And the SPA lives in web/src, Nuxt UI only, design tokens only, EN and FR in i18n/messages.ts, no comments in code Rule: Backend: DELETE /v1/user/{id} Background for this Rule (house style of roles_permissions_users_api.feature): Given the route is DELETE /v1/user/{id}?expected_role_id=, mounted under /v1/user inside the JWT group of api/router.go, gated by container.require(role.KeyUsersRemove) And expected_role_id is the id of the role the client believes the member holds (the id of the role of the list item the person saw); it is required, a uint32 greater than 0 And a query parameter is used because a DELETE body is dropped by some proxies; no request body is read And the layers touched are: internal/domain/user (Repository gains DeleteIfRole(id, roleID uint32) error, a compare-and-swap that deletes only if the stored role_id still equals roleID and answers ErrRoleChanged otherwise, ErrUserNotFound when the row is gone), internal/domain/audit (ActionUserRemove = "user.remove"), the new use case internal/infrastructure/useCases/RemoveUser.go (RemoveUserUseCase, RemoveUserCommand{Caller, TargetID, ExpectedRoleID}), persistence/repositories userRepository.go, a new handler method on api/handlers/user, the route in api/router.go, api/dependecyInjection.go, the integration catalogue and docs/api*.md And no handler reaches a repository directly, and no domain package imports gorm And the order of checks is: gate users.remove (403 missing_permission), id format (400 with the existing uint32 message), expected_role_id (400 invalid_role), target lookup (404 user_not_found), target is the caller (403 own_role), target role differs from expected_role_id (409 user_role_changed), target role locked (403 role_locked), target role not covered by the caller (403 escalation), then DeleteIfRole And only accepted users are removable; invites are cancelled through DELETE /v1/user/invite/{id}, unchanged Scenario Outline: Answers Given the world of features/roles_permissions_users_api.feature (Owner Marta Alves, Admin Jonas Weber, Ines Duarte Writer, Lea Martin Content lead, a People manager with users.view and users.remove) When sends DELETE /v1/user/?expected_role_id= Then the answer is And Examples: | caller | target | expected | status | body | effect | | the Owner | Ines Duarte | Writer id | 204 | (empty) | the user is gone from GET /v1/user and one user.remove event is recorded | | an Admin | Ines Duarte | Writer id | 204 | (empty) | the same | | a People manager | Ines Duarte | Writer id | 204 | (empty) | the same when the manager's role covers Writer | | the Owner | Ines Duarte | Admin id | 409 | user_role_changed | nothing removed, no event | | the Owner | Ines Duarte | (absent) | 400 | invalid_role | nothing removed | | the Owner | Ines Duarte | 0 | 400 | invalid_role | nothing removed | | the Owner | abc | Writer id | 400 | the uint32 id message | nothing removed | | the Owner | 9999 | Writer id | 404 | user_not_found | nothing removed | | the Owner | the Owner (self) | Owner id | 403 | own_role | nothing removed | | an Admin | Jonas Weber (self) | Admin id | 403 | own_role | nothing removed | | an Admin | Marta Alves | Owner id | 403 | role_locked | the owner is never removable; nothing removed | | a People manager | Lea Martin | Content lead id | 403 | escalation | X-Escalation-Key names the first key the manager lacks; nothing removed | | a People manager | Jonas Weber | Admin id | 403 | escalation | a role above the caller's power cannot be removed | | a user without users.remove | Ines Duarte | Writer id | 403 | missing_permission | X-Missing-Permission is users.remove; the target is not even looked up | | no cookie | Ines Duarte | Writer id | 401 | the existing 401 text | nothing removed | And a stale role beats the coverage check: a target whose role changed answers 409 before 403 escalation or role_locked And a refusal (400, 403, 404, 409) records no audit event and changes nothing Scenario: A race between the check and the delete Given the target's role changes after the use case read it and before DeleteIfRole runs Then DeleteIfRole answers ErrRoleChanged, the route answers 409 user_role_changed and the user remains Scenario: A failed write Given the repository fails with an error that is not a refusal Then the route answers 500 "Internal server error", the user remains and one user.remove event with outcome failed is recorded Scenario: What a removal revokes Given user 41 has a session cookie and an API key rcms_… that user 41 created When user 41 is removed Then the next request with user 41's cookie answers 401 "You must be logged in to access this resource." And the next request with user 41's key answers 401 (a key whose user was deleted) And no other user's session or key changes Scenario: What a removal leaves Then articles, entries and media created by user 41 stay, with their created_by id unchanged And invites sent by user 41 stay And the audit events whose actor is user 41 stay and keep naming them Scenario: The audit event When user 41 "ines" is removed by the Owner Then one event is recorded with action user.remove, category users, outcome success, actor the caller, target {type "user", id 41, name "ines"} And the event is shown in /activity with the category "Users and invites" Scenario: Layering Then internal/domain/user imports no gorm, chi or infrastructure, the handler imports no persistence, and RemoveUserUseCase depends only on domain interfaces Rule: Activity sentences for user.remove Scenario Outline: Sentences Given GET /v1/activity returns an event user.remove, target Ines Duarte, with outcome Then the sentence reads and in FR Examples: | outcome | sentence | french | | failed | Failed to remove Ines Duarte | Échec du retrait d'Ines Duarte | | success | Removed Ines Duarte | Ines Duarte retiré | And the success sentence and its FR are not in the design (design gap G2); the failed pair is the design's And the security chip does not show on user.remove Rule: Row menu and gating Scenario Outline: What the row menu offers Given the session grants When the person opens the menu on Then "Remove user" is Examples: | grants | row | state | | users.view, users.remove | Ines Duarte (accepted) | enabled, below "Change role" when that is offered | | users.view, users.change_role | Ines Duarte (accepted) | absent | | users.view, users.remove | the owner row | disabled with "The owner can't be changed or removed. Transfer ownership first." | | users.view, users.remove | the row marked "you" | disabled with a reason (copy TBD, gap G1) | | users.view, users.remove | a pending or expired invite | absent (invites keep "Cancel invite" and "Renew invite") | | the Owner | Ines Duarte (accepted) | enabled | And a person with users.view and users.remove only sees a menu with "Remove user" and nothing else And "Send password reset" stays out of this slice Scenario: Roles that cannot be covered Given the session cannot cover Content lead Then "Remove user" is disabled on a Content lead row with the reason "Your role can't assign a role with more power than yours." (reuse of the no-escalation sentence, to confirm in Q-RM1) Rule: Removing Scenario: The confirmation dialog (behaviour only) When "Remove user" is chosen on Ines Duarte (Writer) Then a confirmation dialog opens with Cancel and a destructive confirm action And it names Ines Duarte (title and body copy TBD, gap G1) Scenario: Confirming When the person confirms Then DELETE /v1/user/?expected_role_id= is sent And while in flight the dialog cannot be dismissed and both buttons are disabled And on 204 the dialog closes, the row disappears without reloading the whole list, and a success toast shows (copy TBD, gap G1) And the count line of the Users page follows Scenario Outline: Remove answers When the server answers Then Examples: | status | code | effect | | 409 | user_role_changed | the dialog closes, GET /v1/user is re-read and a warning toast reads "User not removed · Ines Duarte's role changed in the meantime, so nothing was removed. Check it and try again. · Reload" | | 404 | user_not_found | the list is re-read, the row is gone, the dialog closes and the existing toast "That user no longer exists" / "The list was refreshed." shows | | 403 | escalation | "You can't grant that" / "You can only grant what your role has." (shared refusal copy) and the dialog closes | | 403 | role_locked | "This role is locked" / "The Owner role can't be changed or deleted. Transfer ownership from Users instead." | | 403 | own_role | "You can't change your own role" / "Ask another owner or admin." (shared refusal copy, wording gap G3) | | 403 | missing_permission | the shared refusal toast "You don't have permission to do that" / "Your role doesn't include Remove users. Ask an owner or admin." and the dialog closes | | 500 | (network) | the dialog stays open with an inline error (copy TBD, gap G1) and the confirm action stays enabled | And in FR the warning toast reads "Utilisateur non retiré · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été retiré. Vérifiez-le et réessayez. · Recharger" And "Reload" refetches GET /v1/user and dismisses the toast And the warning toast stays until dismissed or Reload is pressed Rule: The API reference Scenario: The route stays out of the API reference until the design draws it Then DELETE /v1/user/{id} is not added to web/src/data/apiCatalogue.ts, because the Integration board has no card for it And "DELETE /v1/user/{id}" is added to routesNotYetInTheCatalogue in test/api_catalogue_drift_test.go so TestRouterRoutesAreInTheCatalogue stays satisfied And the "Users and invites" group still has 6 routes