feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows

This commit is contained in:
fsylvain committed 2026-09-30 02:48:49 +02:00
1 parent 1ece778caf
commit 142325730d
14 files changed
+1156 -35

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
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 "<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 <result>
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 <failure>
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 <failure>
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
+98
View File
@@ -0,0 +1,98 @@
# QA: Users row actions (Cancel invite, Renew invite)
## Implementation note for coder (informational, not a QA assertion)
No Go change. Toasts use useToast().add({ id, color, icon, title, description }):
- cancelled: id "invite-cancelled-{id}", color success, icon i-lucide-circle-check, title "Invite cancelled",
description "{email} can no longer use the link."
- renewed: id "invite-renewed-{id}", color success, icon i-lucide-circle-check, title "Invite renewed",
description "{email} has 3 days to accept the new link."
- renew failed: id "invite-renew-failed-{id}", color error, icon i-lucide-circle-x, title "Couldn't renew the invite",
description "Try again in a moment."
Cancel runs through ConfirmDialog (tone "danger", cancelLabel "Keep invite", confirmLabel "Cancel invite",
pendingLabel "Cancelling…"); its action throws an Error whose message is "Couldn't cancel the invite. Try again in
a moment." for any failure except 401/403 (global handling), so the dialog shows it. Remove the 10.5 % exception
from the "users" > Default state in web/visual/states.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` for GET /v1/user/, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew. Mount
UsersPage inside DashboardShell with a verified owner session (id 1, "marta", role "owner") and a test router at
/users. Default fixture as in features/users_screen.qa.md. No test freezes the system date; the renewed row's
"Today" label is not asserted. Run layout cases at desktop and below-md widths (mock matchMedia).
1. Default actions: row 1 has "Cancel invite" (user-x), row 2 "Renew invite" (refresh-cw), Accepted rows have
none; at below-md the same buttons sit last in the cards.
2. Non-owners, Examples-driven: role "admin", role absent, role "owner"; assert presence or absence of both
buttons at both widths.
3. Cancel invite: confirm: click "Cancel invite" on row 1; assert dialog title, text with the address, "Keep
invite", "Cancel invite", and that `request` was not called with DELETE.
4. Keep invite: click it; assert the dialog closed, no DELETE, 5 rows.
5. Cancel invite: sending: unresolved DELETE; assert one call to DELETE /v1/user/invite/2 (no body), "Cancelling…",
"Keep invite" disabled, Escape and backdrop click do not close.
6. Cancel invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
NetworkError; assert the dialog stays open with the notice "Couldn't cancel the invite. Try again in a moment."
once (never the raw body), buttons re-enabled, 5 rows, no toast.
7. Cancel invite: retry: after 6, resolve 204 on the second click; assert two DELETE calls, dialog closed, row gone.
8. Cancel invite: done: resolve 204; assert dialog closed, row 1 gone, 4 rows in order, no second GET, toast
raised with the id, color, icon, title and description above.
9. 401 and 403 on DELETE and on renew: assert the global handling runs and no local notice or toast is added.
10. Renew invite: sending: unresolved POST; assert one call to POST /v1/user/invite/1/renew with no body, no
dialog, the row-2 button reads "Renewing…", is disabled, has the loader-circle icon and 50 % opacity class; row
1's "Cancel invite" is enabled; a second click sends nothing.
11. Only the busy row is busy: two Expired rows; renew one, assert the other button is enabled and usable.
12. Renew invite: done: resolve 200 with the response shape; assert "p.huber@gmail.com" is first, Pending, has the
--ui-bg-elevated class, keeps "Paul Huber", ends with "Cancel invite", "sam.oliveira@renew.example" second, one
row for the address, no second GET, and the toast (id, color, icon, title, description).
13. Renew invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
NetworkError; assert the error toast, the row still Expired in place, the button enabled again with the label
"Renew invite".
14. Renew retry: after 13, resolve 200; assert the success toast and the row on top.
15. Interplay: a row from a fresh invite (201) shows "Cancel invite"; cancelling a just-renewed row removes it and
raises "Invite cancelled".
16. Invite-only rule: assert no "Renew invite" on a Pending row and no "Cancel invite" on an Expired row.
## Visual (task visual:check -- users, desktop and mobile, light and dark)
Reference: docs/design/screens/users.md; board group "Users" in `docs/design/source/RenewCMS Screens
Back-office.dc.html`; exact values in `docs/design/source/AppFrame.dc.html`. Layout (button size, position in the
sixth column and on the card, dialog geometry) is taken from the board frame, not components.md. The gate is hard:
every capture of every non-skipped state must be within 10 %, both viewports, both themes. Use the board's sample
data: the existing `usersFixture` (sam.oliveira@renew.example pending 27 Sep 2026; p.huber@gmail.com "Paul Huber"
expired 3 Aug 2026), session marta/owner. Add these 7 states to `users` in web/visual/states.ts:
- [ ] Cancel invite: confirm: steps click "Cancel invite" on the sam.oliveira row; dialog "Cancel this invite?" with
text naming sam.oliveira@renew.example, "Keep invite", "Cancel invite".
- [ ] Cancel invite: sending: mock `DELETE /v1/user/invite/2` status 204 with `hang: true`; steps open the dialog and
click "Cancel invite"; "Cancelling…" loading, "Keep invite" disabled.
- [ ] Cancel invite: failed: mock `DELETE /v1/user/invite/2` status 500; steps open the dialog and confirm; the
notice "Couldn't cancel the invite. Try again in a moment." inside the dialog, buttons enabled.
- [ ] Cancel invite: done: `{ path: '/users', skip: 'clock-sensitive' }` (per the user's request; the design does
not mark it clock-sensitive). Prints SKIP, never investigated or date-frozen. Vitest case 8 covers it.
- [ ] Renew invite: sending: mock `POST /v1/user/invite/1/renew` status 200 with `hang: true`; steps click "Renew
invite" on the p.huber row; only that button shows "Renewing…", loader-circle, 50 % opacity.
- [ ] Renew invite: done: `{ path: '/users', skip: 'clock-sensitive' }` ("Today"). Vitest case 12 covers it.
- [ ] Renew invite: failed: mock `POST /v1/user/invite/1/renew` status 500; steps click "Renew invite"; the error
toast "Couldn't renew the invite" / "Try again in a moment." is visible and the p.huber row is Expired with
"Renew invite". Keep the toast on screen for the capture; if the runner can't, stop and ask.
- [ ] Default: with the exception removed it passes at 10 % with the drawn buttons; if it fails, stop and ask (no
exception without approval).
- [ ] Not drawn, no visual state: non-owner rendering and the 404 fallback. Vitest only (cases 2, 6, 13).
- [ ] Not covered by screenshots: keyboard order (Tab reaches each row action; in the dialog focus starts on "Keep
invite", is trapped, Esc closes it except while Cancelling…, focus return after the dialog closes); hover and
active on the buttons; motion (modal 240 ms, toast enter and exit), stopped under reduced motion.
## Manual verification
- Real round trips against `task dev`, as owner: cancel a pending invite (row disappears, the old link at
/invite/accept?c=... answers "This invite link doesn't work."), renew an expired one (row moves to top with
"Today", old link dead, new link works), and reload to confirm the order matches the API.
- Real invitation email on renew needs real or sandboxed SMTP (`SMTP_HOST` and related vars): confirm the new mail
arrives with the new link and the old one no longer works.
- Failure paths with the API stopped or SMTP failing: the renew toast and the cancel dialog notice appear, and a
retry succeeds.
- Admin session against `task dev`: no row actions are shown.
- Toast placement and stacking on desktop (bottom right) and above the pinned "Invite user" button on mobile.
- Focus after a successful cancel (the row is gone) lands somewhere sensible.
- "Today" and the elevated row on a live app after Renew, since visual:check skips it.
+2 -3
View File
@@ -35,7 +35,7 @@ Feature: Users screen and InviteUserModal
the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state
and is covered by Vitest only
And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both
fed by the same items array; rows have no actions and are not clickable
fed by the same items array; rows are not clickable; row actions are specified in features/users_row_actions.feature
And UsersTable has the columns Email, Display name, Role, Invite status and Created, plus an
unlabeled, empty actions column kept so the grid matches the board: grid
minmax(0,1.3fr) minmax(0,1fr) 84px 120px 110px 150px with a 12 px gap, a 36 px header on
@@ -106,8 +106,7 @@ Feature: Users screen and InviteUserModal
form is not disabled by an in-flight request; focus returns to the button that opened it
And a 401 and a 403 (unverified) on either endpoint are handled entirely by useApi's global
handling (features/api_client_global_error_handling.feature)
And this slice does not build the accept-invite screen (/invite/accept) and does not add row
actions (Cancel invite, Renew invite); the "Display names in the Users list" state of the
And this slice does not build the accept-invite screen (/invite/accept); the "Display names in the Users list" state of the
Profile group is drawn by this same list
Scenario: Default — the list loads newest first with email, display name, role, status and created
+1 -2
View File
@@ -96,8 +96,7 @@ capture of every non-skipped state must be within 10 %, for both viewports and b
are updated so "Display names in the Users list" shows "Marta Alves", owner.
- [ ] Default: the 5 board rows by email with display names, roles and status badges, dates
27 Sep 2026, 3 Aug 2026, 18 Jun 2026, 2 Apr 2026, 12 Mar 2026; session user `marta`. The
board also draws Cancel/Renew buttons in the sixth column, which this slice does not
render; if the gate fails on them, stop and ask the user (no exception without approval).
Cancel/Renew buttons in the sixth column are specified in features/users_row_actions.feature.
- [ ] Loading: 5 skeleton rows, with role and status placeholders on desktop and the board's
two-bar cards on mobile; the setup must give skeletons time to show (150 ms) before the
capture.