feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows
This commit is contained in:
1 parent
1ece778caf
commit
142325730d
14 files changed
+1156
-35
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user