# 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.