Feature: Role 409 conflicts: stale save, change role, transfer ownership and a missing reassign target (group 15) As a person editing a role, changing a member's role or handing over ownership I want a stale request to say who or what changed, to keep what I entered and to offer the next step So that nothing is overwritten and I never lose my edits Background: Given the spec of record is docs/design/screens/roles.md groups 12, 13 and 15, the table "HTTP status to copy" rows role_conflict, and docs/design/states.md "Stale state" And the backend contract is, after this slice: | call | status | body | | PUT /v1/role/{id} with a stale version | 409 | role_conflict | | PUT /v1/user/{id}/role | 409 | user_role_changed | | POST /v1/site/transfer-ownership | 409 | user_role_changed | | DELETE /v1/user/{id} | 409 | user_role_changed | | DELETE /v1/role/{id} while it has members | 409 | role_has_members | | POST /v1/role/{id}/reassign, target missing | 409 | target_role_not_found | | POST /v1/role/{id}/reassign, same role, locked or uncovered target | 400 | invalid_reassign | | any action on a deleted role (the path role) | 404 | role_not_found | And a 409 body is plain text and names nobody: the SPA re-reads GET /v1/role/{id} (updated_by, updated_at) for a stale role save, and GET /v1/user for the member's current role And the removal route and its 409 are specified in features/roles_sync_remove_user.feature And the message appears where the person is working (banner in the editor, alert in a dialog) and never as a toast while that surface is open; a toast shows only when the surface is gone And a warning toast stays until dismissed or Reload is pressed; an info toast closes after 6 s 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: a missing reassign target is a distinct 409 Background for this Rule (house style of roles_permissions_roles_api.feature): Given the new domain sentinel role.ErrReassignTargetNotFound and the new apperr string ReassignTargetNotFoundErrorMessage = "target_role_not_found" And ReassignRoleMembersUseCase.checkTarget returns it when roles.Get(target) answers role.ErrRoleNotFound, instead of role.ErrInvalidReassign And api/handlers/role maps it to 409 with the plain-text body, in the existing refusals table And target 0, target equal to the source, a locked target and a target the caller cannot cover still answer 400 invalid_reassign And a missing source role still answers 404 role_not_found And nothing is moved and no event is recorded on this refusal (a refusal records nothing) And docs/api.md documents the 409 on POST /v1/role/{id}/reassign Scenario Outline: Reassign target answers When the Owner sends POST /v1/role//reassign {"target_role_id": } Then the answer is And no member moved Examples: | source | target | status | body | | Contributor | an id that no longer exists | 409 | target_role_not_found | | Contributor | Contributor (itself) | 400 | invalid_reassign | | Contributor | 0 | 400 | invalid_reassign | | Contributor | Owner (locked) | 400 | invalid_reassign | | Contributor | Admin, caller is a People manager | 400 | invalid_reassign | | 9999 | Writer | 404 | role_not_found | Rule: Stale role save (editor) Scenario: The banner Given Contributor was loaded at version 2 and the person made 2 edits When PUT /v1/role/4 answers 409 role_conflict and GET /v1/role/4 answers updated_by "Jonas Weber", updated_at 11:02 Then a banner above the form reads "This role changed while you were editing · Jonas Weber saved changes to Contributor at 11:02. Reload to see them, or review how they differ from your edits." with "Reload role" and "Review differences" And the edits are kept, Save is off, and the bar reads "2 unsaved changes. Save is off until you reload" Scenario: Review differences Given the banner is shown with edits Events Update Own to All and Events Delete None to Own against the saved role When "Review differences" is pressed Then a dialog "How the saved role differs from your edits · Jonas Weber saved changes to Contributor at 11:02." lists only the settings where the two disagree, as a table Setting, Saved, Yours: Events Update Own All; Events Delete None Own And it offers "Use the saved role" and "Keep my edits" And in FR the title is "Comment le rôle enregistré diffère de vos modifications" with "Réglage", "Enregistré", "Vous", "Utiliser le rôle enregistré", "Garder mes modifications" Scenario Outline: Resolving When the person presses Then Examples: | action | effect | | Keep my edits | GET /v1/role/4 replaces the saved role and its version, the edits are re-applied on top, changed cells carry the square marker, Save is on, the dialog and the banner close, an info toast reads "Role reloaded · Your 2 changes are still here. Review them, then save." and the bar reads "2 unsaved changes" | | Use the saved role | the edits are dropped, the form shows the saved role, Save is off, no toast about edits | | Reload role (banner) | the same as "Use the saved role" | | Escape or close | the dialog closes and the banner and kept edits stay | And "Keep my edits" followed by Save sends PUT /v1/role/4 with the reloaded version And in FR the toast reads "Rôle rechargé · Vos 2 modifications sont toujours là. Relisez-les, puis enregistrez." Scenario: A second conflict Given edits were kept on top of version 3 When the save answers 409 role_conflict again Then the banner returns with the newest updated_by and time, and the edits are still kept Scenario: The name taken is not a conflict When the save answers 409 role_name_taken Then the existing field error shows and no banner Scenario: The time is not clock-sensitive Given updated_at is a fixed string and the time zone is pinned in the test and in the visual setup Then "11:02" is the formatted time of that string, not the current time Rule: Change role when the member's role changed first Scenario: The alert in the dialog Given the Change role dialog for Ines Duarte (Writer) is open with Contributor chosen When PUT /v1/user/{id}/role answers 409 user_role_changed and GET /v1/user answers Ines Duarte with role Admin Then the dialog stays open with an alert "Role changed in the meantime · Ines Duarte is now Admin. Your choice is kept. Check it and try again." And Contributor stays chosen, the "Current" mark moves from Writer to Admin, and "Change role" stays enabled And pressing "Change role" again sends PUT /v1/user/{id}/role {"role_id": } And in FR "Rôle modifié entre-temps · Ines Duarte a maintenant le rôle Admin. Votre choix est conservé. Vérifiez-le et réessayez." And no toast shows while the dialog is open Scenario: The chosen role became the current one Given the re-read shows Ines Duarte now holds Contributor, the role the person chose Then the alert still shows and "Change role" is disabled because the chosen role is now the current one Scenario: The member disappeared When GET /v1/user no longer lists Ines Duarte, or PUT answers 404 user_not_found Then the dialog closes, the list is refreshed and the existing "no longer exists" toast of slice 7 shows Scenario: The dialog is gone when the answer arrives (component level) Given the dialog component was unmounted or its open state set to false before the 409 arrived Then a warning toast reads "Role not changed · Ines Duarte's role changed in the meantime, so nothing was saved. Reload and try again. · Reload" And "Reload" refetches GET /v1/user and dismisses the toast And in FR "Rôle non modifié · Le rôle d'Ines Duarte a changé entre-temps, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger" And this is specified at component level only, no page-level trigger exists because the dialog cannot be dismissed while sending Rule: Transfer ownership when the target's role changed first Scenario: The alert in the dialog Given the Owner confirmed Jonas Weber and typed "jonas@renewbuild.example" When POST /v1/site/transfer-ownership answers 409 user_role_changed and GET /v1/user answers Jonas Weber with role Writer Then an alert in the dialog reads "Role changed in the meantime · Jonas Weber is now Writer. What you typed is kept. Check the change, then try again." with "Reload member" And the typed email is kept and "Transfer ownership" is disabled until "Reload member" refreshes the target And "Reload member" re-reads GET /v1/user, updates the shown role of the target and re-enables "Transfer ownership" with the typed email still valid And in FR "Rôle modifié entre-temps · Jonas Weber a maintenant le rôle Writer. Ce que vous avez saisi est conservé. Vérifiez le changement, puis réessayez. · Recharger le membre" Scenario: The dialog is gone when the answer arrives (component level) Then a warning toast reads "Ownership not transferred · Jonas Weber's role changed in the meantime, so nothing was transferred. Check it and try again. · Reload" And in FR "Propriété non transférée · Le rôle de Jonas Weber a changé entre-temps, donc rien n'a été transféré. Vérifiez-le et réessayez. · Recharger" Scenario Outline: Other transfer answers keep the slice 7 behaviour When the server answers Then the behaviour of features/roles_spa_users.feature "Transfer answers" holds Examples: | status | code | | 400 | invalid_transfer | | 400 | confirmation_mismatch | | 403 | missing_permission | | 500 | (network) | Rule: Role list actions Scenario: Role edited elsewhere (409 on a list action) Given the roles list was loaded and Contributor was edited by Jonas Weber afterwards When Duplicate, Delete or Move members answers 409 role_conflict Then a warning toast reads "Role changed elsewhere · Jonas Weber edited Contributor, so nothing was saved. Reload and try again. · Reload" And "Reload" refetches GET /v1/role And in FR "Rôle modifié ailleurs · Jonas Weber a modifié Contributor, donc rien n'a été enregistré. Rechargez et réessayez. · Recharger" Scenario: Role deleted elsewhere (404) When an action answers 404 role_not_found Then the row is removed from the list and an info toast reads "That role no longer exists · It was deleted somewhere else, so we removed it from the list." and closes after 6 s And in FR "Ce rôle n'existe plus · Il a été supprimé ailleurs, nous l'avons donc retiré de la liste." Scenario: Reassign target deleted (409 target_role_not_found) Given the Delete role dialog of Intern is open with Writer chosen When POST /v1/role/{id}/reassign answers 409 target_role_not_found Then the inline error "Writer no longer exists · It was deleted somewhere else. Choose another role to move the members to." of features/roles_sync_copy_activity.feature shows in the dialog, never a toast And GET /v1/role is re-read so the picker no longer offers Writer