Deleting a custom role — the `Delete` affordance on the detail screen and the reassign dialog behind it
The one destructive surface in this story: a project admin removes a role they authored, and the people holding it land somewhere deliberate rather than nowhere.
The flow
Delete sits on a custom role's detail screen only — a built-in's head carries a lock and no control at all, which is what the shipped screen already renders. Pressing it calls DELETE with no destination, which is the cheap way to learn the truth: the API answers 409 with the member count, and that count is what the dialog opens with.
- Nobody holds the role — a plain destructive confirm, no picker.
- Somebody holds it — the dialog names how many, and requires a destination before its confirm is enabled: another of the project's roles, built-in or custom, never this one. Confirming re-issues
DELETEwith the destination, and the move and the removal happen together on the server.
After a successful delete the admin is returned to the role list, which no longer contains the role and shows the destination's member count increased. That page-state routing follows the shipped settings convention rather than a hand-rolled refetch.
Where this sits beside its siblings
This card owns exactly two elements of the roles screens — the Delete button and its dialog. Create role and Edit belong to the editor card, which owns them because they are the doors into the surface it builds; this card adds neither, and the two cards touch the same two files in different places. Both read the actor's project:manage_access from the server component through the shipped projectAccessService, so neither invents a capability read.
Scope boundary
In: the Delete button on a custom role's detail screen, the reassign dialog in both its states, the client call and its refusal handling, the post-delete navigation, and the en + zh strings for all of it. Out: Create role and Edit, which the editor card owns; the cap-reached state, which sits on Create role; any change to what the screens DISPLAY about a role, which the read card owns; hiding the Roles entry from an actor who cannot use it, which is the permission-gated UI story's.
Acceptance criteria
- A custom role's detail screen renders
Deletefor an actor holdingproject:manage_access, and renders it for nobody else; a built-in role's screen renders no delete affordance for any actor, including a workspace owner. - The dialog composes the shipped
Modaland matches the amended asset — the role name, the member count when it is non-zero, the destination picker, and a destructive confirm — with no new dialog grammar invented. - With members, the confirm is disabled until a destination is chosen; the picker lists the project's other roles and never the role being deleted.
- With no members, no picker is shown and the confirm is immediately available.
- The member count the dialog displays comes from the 409 response body, not a second request and not a client-side count.
- A refusal returned on confirm — the role was deleted by someone else, the destination vanished, permission was lost — surfaces as a toast with its own message and leaves the dialog open; the screen never silently no-ops.
- After a successful delete the admin lands on the role list with the role gone and the destination's member count updated, through the shipped page-state-after-mutation routing for a settings surface.
- The dialog is keyboard-operable and focus-managed like every other
Modalin the product: focus moves in on open,Esccloses, focus returns toDelete. - Every string is in
messages/en.jsonandmessages/zh.json; no literal copy in the component. Inks follow the asset's measured contrast rules —--el-text-faintcarries no text WCAG measures. - Component tests cover both dialog states, the disabled-confirm rule, the refusal path, and the built-in-role case where no button exists.
Context refs
design/projects/roles-permissions.mock.htmlpanel 2 (the custom role's head withEdit/Delete) and the dialog panel the design amendment adds;design/projects/design-notes.md§ Panels and its contrast table.app/(authed)/settings/project/roles/_components/RoleDetail.tsx— the screen this extends.app/(authed)/settings/project/workflow/_components/WorkflowEditor.tsx— the shippedReassignModal, the in-use-count-from-the-refusal pattern, and its toast handling.components/ui/Modal.tsx·components/ui/Combobox.tsx·components/ui/Button.tsx— the primitives composed.motir-core/CLAUDE.md— the page-state-after-mutation routing contract for a settings surface.- The API card — the
DELETEroute, its 409 shape and the count it carries; the design amendment — the dialog this builds to.