From 142325730ddc1ebbbbca6445b277afe5156f4098 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Wed, 30 Sep 2026 02:48:49 +0200 Subject: [PATCH] feat: users row actions "Cancel invite" on Pending rows and "Renew invite" on Expired rows --- features/users_row_actions.feature | 174 ++++++ features/users_row_actions.qa.md | 98 +++ features/users_screen.feature | 5 +- features/users_screen.qa.md | 3 +- web/src/components/ConfirmDialog.vue | 20 +- web/src/components/UserCardList.vue | 17 + web/src/components/UserRowAction.vue | 66 ++ web/src/components/UsersTable.vue | 18 +- web/src/composables/useUsers.ts | 10 +- web/src/views/UsersView.vue | 103 +++- .../views/__tests__/UsersRowActions.spec.ts | 577 ++++++++++++++++++ web/src/views/__tests__/UsersView.spec.ts | 31 +- web/visual/check.ts | 4 +- web/visual/states.ts | 65 +- 14 files changed, 1156 insertions(+), 35 deletions(-) create mode 100644 features/users_row_actions.feature create mode 100644 features/users_row_actions.qa.md create mode 100644 web/src/components/UserRowAction.vue create mode 100644 web/src/views/__tests__/UsersRowActions.spec.ts diff --git a/features/users_row_actions.feature b/features/users_row_actions.feature new file mode 100644 index 0000000..c8dc3b5 --- /dev/null +++ b/features/users_row_actions.feature @@ -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 "" 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 diff --git a/features/users_row_actions.qa.md b/features/users_row_actions.qa.md new file mode 100644 index 0000000..f4e2da8 --- /dev/null +++ b/features/users_row_actions.qa.md @@ -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. diff --git a/features/users_screen.feature b/features/users_screen.feature index ad3f6b7..904e5a2 100644 --- a/features/users_screen.feature +++ b/features/users_screen.feature @@ -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 diff --git a/features/users_screen.qa.md b/features/users_screen.qa.md index 037283b..266ac56 100644 --- a/features/users_screen.qa.md +++ b/features/users_screen.qa.md @@ -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. diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue index 9231dc8..eb8f9d3 100644 --- a/web/src/components/ConfirmDialog.vue +++ b/web/src/components/ConfirmDialog.vue @@ -85,22 +85,24 @@ function onCancel() { }" :ui="{ content: - 'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0', - header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5', - title: 'text-lg max-sm:text-[1.0625rem]/6', - description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]', - body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5', - footer: 'p-6 sm:px-6 max-sm:p-5', + 'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0', + header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6', + title: 'text-lg/6 font-semibold', + description: 'mt-1 text-sm text-muted text-pretty', + body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0', + footer: 'px-6 py-5 sm:px-6', }" > -