Feature: Users row actions: Cancel invite and Renew invite As the owner of a RenewCMS workspace I want to cancel a pending invite and renew an expired one from the Users list So that I can correct a wrong address or resend a lapsed link without leaving the page Background: Given /users is the verified route of features/users_screen.feature, rendered as UsersPage with UsersTable (md and up) or UserCardList (below md), per docs/design/screens/users.md And the backend already exists (features/invite_management.feature) and this slice changes no Go: DELETE /v1/user/invite/{id} answers 204 with an empty body, and POST /v1/user/invite/{id}/renew answers 200 with {id, kind "invite", email, status "pending", created_at, expires_at} And useUsers gains cancelInvite(id), which sends DELETE /v1/user/invite/{id} with no body, and renewInvite(id), which sends POST /v1/user/invite/{id}/renew with no body And the actions are shown only when the session user's role from GET /v1/auth/me is "owner"; for "admin", or any other or absent value, no action button is rendered and the sixth column stays empty; this is not drawn in docs/design, so it has no visual state and is covered by Vitest only And the sixth column (desktop) or the last line of the card (below md) holds at most one action: "Cancel invite" on a Pending row, "Renew invite" on an Expired row, nothing on an Accepted row And each action is an outline button: 28 px high on desktop (12 px medium text, 13 px icon, 9 px side padding, --ui-border-accented border, --ui-bg fill, no wrapping) and 44 px high below md (14 px side padding, 14 px icon); UButton variant "outline" size "xs" And "Cancel invite" has the icon user-x and text in --ui-error; "Renew invite" has the icon refresh-cw and text --ui-text-highlighted And below md the action is the last line of the card, under the badges line And "Cancel invite" opens ConfirmDialog (tone "danger") through useOverlay: title "Cancel this invite?", description "The link sent to {email} will stop working. You can invite the address again later.", cancel button "Keep invite", confirm button "Cancel invite", pending label "Cancelling…"; the action runs inside the dialog, so the failure notice shows in the dialog And "Renew invite" asks nothing and sends the request at once And a request failure is any 500, any network failure that never reaches the server, and any other status except 401 and 403; 401 and 403 are handled entirely by useApi's global handling (features/api_client_global_error_handling.feature); the response body text is never shown verbatim And the fixture is the Default fixture of features/users_screen.feature: invite id 2 "sam.oliveira@renew.example" pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" with display_name "Paul Huber" expired 3 Aug 2026; users 3, 2, 1; the session user is "marta", role "owner"; rows are keyed by kind and id Scenario: Default: actions sit on the right of the Pending and Expired rows only Given GET /v1/user/ resolves 200 with the Default fixture When the owner navigates to /users at desktop Then the first row "sam.oliveira@renew.example" ends with "Cancel invite" (icon user-x, text --ui-error) And the second row "p.huber@gmail.com" ends with "Renew invite" (icon refresh-cw) And the three Accepted rows have an empty sixth cell And below md the same two cards end with the same buttons as 44 px buttons Scenario Outline: Non-owners see no row actions Given the session user has role "" and GET /v1/user/ resolves 200 with the Default fixture When the user navigates to /users at desktop and at a below-md width Then Examples: | role | result | | admin | no "Cancel invite" and no "Renew invite" button exists | | (none) | no "Cancel invite" and no "Renew invite" button exists | | owner | "Cancel invite" and "Renew invite" are present once each | Scenario: Cancel invite: confirm Given the Default state has loaded When the owner activates "Cancel invite" on the "sam.oliveira@renew.example" row Then a danger modal opens titled "Cancel this invite?" And its text reads "The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later." And its buttons are "Keep invite" and "Cancel invite" (danger), and no request has been sent Scenario: Cancel invite: "Keep invite" closes the dialog and changes nothing Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example" When the owner activates "Keep invite" Then the dialog closes, DELETE /v1/user/invite/2 is never sent and all 5 rows remain Scenario: Cancel invite: sending Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example" When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 has not resolved Then DELETE /v1/user/invite/2 was sent once And the confirm button reads "Cancelling…" with a loading indicator And "Keep invite" is disabled And Esc and a backdrop click do not close the dialog Scenario Outline: Cancel invite: failed Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example" When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 Then the dialog stays open, with an error notice inside it reading "Couldn't cancel the invite. Try again in a moment." And "Keep invite" and "Cancel invite" are enabled again And all 5 rows remain and no toast is raised Examples: | failure | | resolves with a 500 response | | resolves with a 500 response whose body is some other text | | never reaches the server | | resolves with a 404 response | Scenario: Cancel invite: retrying after a failure Given the dialog shows the failure notice When the owner activates "Cancel invite" again and DELETE /v1/user/invite/2 resolves 204 Then DELETE /v1/user/invite/2 was sent a second time And the dialog closes and the row is removed Scenario: Cancel invite: done Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example" When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 resolves 204 Then the dialog closes And the row "sam.oliveira@renew.example" is gone and the other 4 rows remain in their order And GET /v1/user/ is not called again And a toast appears: color success, icon i-lucide-circle-check, id "invite-cancelled-2", title "Invite cancelled", description "sam.oliveira@renew.example can no longer use the link." Scenario: Cancel invite: an Expired row can't be cancelled from the list Given the Default state has loaded Then the "p.huber@gmail.com" row offers "Renew invite" and no "Cancel invite" Scenario: Renew invite: sending Given the Default state has loaded When the owner activates "Renew invite" on the "p.huber@gmail.com" row and POST /v1/user/invite/1/renew has not resolved Then POST /v1/user/invite/1/renew was sent once with no body and no confirmation was shown And on that row the button reads "Renewing…" with the icon loader-circle at 50 % opacity, disabled And no other row's button changes and the "Cancel invite" button of "sam.oliveira@renew.example" stays enabled And a second activation of the busy button sends nothing Scenario: Renew invite: done Given POST /v1/user/invite/1/renew was sent from the "p.huber@gmail.com" row When it resolves 200 with id 1, kind "invite", email "p.huber@gmail.com", status "pending", created_at now and expires_at 3 days later Then the row moves to the top of the list as Pending, created "Today", drawn with the --ui-bg-elevated background, keeping its display name "Paul Huber" and role "admin", and now ends with "Cancel invite" And "sam.oliveira@renew.example" is now the second row and the other rows are unchanged And exactly one row exists for "p.huber@gmail.com" and GET /v1/user/ is not called again And a toast appears: color success, icon i-lucide-circle-check, id "invite-renewed-1", title "Invite renewed", description "p.huber@gmail.com has 3 days to accept the new link." Scenario: Renew invite: renewing a Pending row is not offered Given the Default state has loaded Then the "sam.oliveira@renew.example" row offers "Cancel invite" and no "Renew invite" Scenario Outline: Renew invite: failed Given the Default state has loaded When the owner activates "Renew invite" on the "p.huber@gmail.com" row and POST /v1/user/invite/1/renew Then a toast appears: color error, icon i-lucide-circle-x, id "invite-renew-failed-1", title "Couldn't renew the invite", description "Try again in a moment." And the row stays Expired in its place, with "Renew invite" enabled again And the list is otherwise unchanged Examples: | failure | | resolves with a 500 response | | resolves with a 500 response whose body is some other text | | never reaches the server | | resolves with a 404 response | Scenario: Renew invite: retrying after a failure Given the failed toast has appeared for "p.huber@gmail.com" When the owner activates "Renew invite" again and POST /v1/user/invite/1/renew resolves 200 Then the "Invite renewed" toast is raised and the row is Pending on top Scenario: Renew invite: only the busy row is busy Given the fixture also holds Expired invite id 7 "old@renew.example" When the owner activates "Renew invite" on "p.huber@gmail.com" and it has not resolved Then "Renew invite" on "old@renew.example" is still enabled and can be activated Scenario: Actions after a new invite is sent Given the Default state has loaded and an invite for "lea.martin@renew.example" was sent (201) Then the new first row ends with "Cancel invite" Scenario: Cancelling a just-renewed row Given "p.huber@gmail.com" was renewed and is the first row When the owner confirms "Cancel invite" for it and DELETE /v1/user/invite/1 resolves 204 Then the row is gone and the toast "Invite cancelled" is raised