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
|
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 is covered by Vitest only
|
||||||
And the list is rendered as UsersTable (desktop, md and up) or UserCardList (below md), both
|
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
|
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
|
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
|
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
|
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
|
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)
|
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
|
And this slice does not build the accept-invite screen (/invite/accept); the "Display names in the Users list" state of the
|
||||||
actions (Cancel invite, Renew invite); the "Display names in the Users list" state of the
|
|
||||||
Profile group is drawn by this same list
|
Profile group is drawn by this same list
|
||||||
|
|
||||||
Scenario: Default — the list loads newest first with email, display name, role, status and created
|
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.
|
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
|
- [ ] 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
|
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
|
Cancel/Renew buttons in the sixth column are specified in features/users_row_actions.feature.
|
||||||
render; if the gate fails on them, stop and ask the user (no exception without approval).
|
|
||||||
- [ ] Loading: 5 skeleton rows, with role and status placeholders on desktop and the board's
|
- [ ] 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
|
two-bar cards on mobile; the setup must give skeletons time to show (150 ms) before the
|
||||||
capture.
|
capture.
|
||||||
|
|||||||
@@ -85,22 +85,24 @@ function onCancel() {
|
|||||||
}"
|
}"
|
||||||
:ui="{
|
:ui="{
|
||||||
content:
|
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',
|
'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-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
title: 'text-lg/6 font-semibold',
|
||||||
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
description: 'mt-1 text-sm text-muted text-pretty',
|
||||||
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
|
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||||
footer: 'p-6 sm:px-6 max-sm:p-5',
|
footer: 'px-6 py-5 sm:px-6',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="error" #body>
|
<template #body>
|
||||||
<UAlert
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
color="error"
|
color="error"
|
||||||
icon="i-lucide-circle-x"
|
icon="i-lucide-circle-x"
|
||||||
:description="error"
|
:description="error"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
:close="false"
|
:close="false"
|
||||||
role="alert"
|
role="alert"
|
||||||
|
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -110,7 +112,7 @@ function onCancel() {
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
block
|
block
|
||||||
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
|
||||||
:disabled="pending"
|
:disabled="pending"
|
||||||
@click="onCancel"
|
@click="onCancel"
|
||||||
>
|
>
|
||||||
@@ -121,7 +123,7 @@ function onCancel() {
|
|||||||
:loading="pending"
|
:loading="pending"
|
||||||
:disabled="pending"
|
:disabled="pending"
|
||||||
block
|
block
|
||||||
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ confirmText }}
|
{{ confirmText }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||||
import RoleBadge from '@/components/RoleBadge.vue'
|
import RoleBadge from '@/components/RoleBadge.vue'
|
||||||
|
import UserRowAction from '@/components/UserRowAction.vue'
|
||||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +17,13 @@ defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
sessionUserId?: number
|
sessionUserId?: number
|
||||||
highlightedKey?: string
|
highlightedKey?: string
|
||||||
|
canManage?: boolean
|
||||||
|
busyKeys?: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [item: UserListItem]
|
||||||
|
renew: [item: UserListItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function rowKey(item: UserListItem) {
|
function rowKey(item: UserListItem) {
|
||||||
@@ -56,6 +64,15 @@ function rowKey(item: UserListItem) {
|
|||||||
<InviteStatusBadge :status="item.status" size="sm" />
|
<InviteStatusBadge :status="item.status" size="sm" />
|
||||||
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="canManage && item.kind === 'invite'" class="flex pt-1">
|
||||||
|
<UserRowAction
|
||||||
|
:item="item"
|
||||||
|
large
|
||||||
|
:busy="busyKeys?.includes(rowKey(item))"
|
||||||
|
@cancel="emit('cancel', item)"
|
||||||
|
@renew="emit('renew', item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { UserListItem } from '@/composables/useUsers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: UserListItem
|
||||||
|
busy?: boolean
|
||||||
|
large?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ cancel: []; renew: [] }>()
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
pending: {
|
||||||
|
label: 'Cancel invite',
|
||||||
|
icon: 'i-lucide-user-x',
|
||||||
|
textClass: 'text-error',
|
||||||
|
event: 'cancel',
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
label: 'Renew invite',
|
||||||
|
icon: 'i-lucide-refresh-cw',
|
||||||
|
textClass: 'text-highlighted',
|
||||||
|
event: 'renew',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const action = computed(() => {
|
||||||
|
return props.item.kind === 'invite' ? actions[props.item.status] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const label = computed(() => (props.busy ? 'Renewing…' : (action.value?.label ?? '')))
|
||||||
|
const icon = computed(() => (props.busy ? 'i-lucide-loader-circle' : action.value?.icon))
|
||||||
|
const sizeClass = computed(() =>
|
||||||
|
props.large ? 'h-11 gap-2 px-3.5 text-sm' : 'h-7 gap-1.5 px-2.25 text-xs',
|
||||||
|
)
|
||||||
|
const iconClass = computed(() => (props.large ? 'size-3.5' : 'size-3.25'))
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (props.busy || !action.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.value.event === 'cancel') {
|
||||||
|
emit('cancel')
|
||||||
|
} else {
|
||||||
|
emit('renew')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
v-if="action"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
:icon="icon"
|
||||||
|
:disabled="busy"
|
||||||
|
class="shrink-0 border border-accented bg-default font-medium whitespace-nowrap ring-0"
|
||||||
|
:class="[sizeClass, action.textClass, { 'opacity-50': busy }]"
|
||||||
|
:ui="{ leadingIcon: iconClass }"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
|
||||||
import RoleBadge from '@/components/RoleBadge.vue'
|
import RoleBadge from '@/components/RoleBadge.vue'
|
||||||
|
import UserRowAction from '@/components/UserRowAction.vue'
|
||||||
import UserEmailCell from '@/components/UserEmailCell.vue'
|
import UserEmailCell from '@/components/UserEmailCell.vue'
|
||||||
import { formatCreatedDate } from '@/lib/relativeDate'
|
import { formatCreatedDate } from '@/lib/relativeDate'
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,13 @@ defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
sessionUserId?: number
|
sessionUserId?: number
|
||||||
highlightedKey?: string
|
highlightedKey?: string
|
||||||
|
canManage?: boolean
|
||||||
|
busyKeys?: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [item: UserListItem]
|
||||||
|
renew: [item: UserListItem]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function rowKey(item: UserListItem) {
|
function rowKey(item: UserListItem) {
|
||||||
@@ -74,7 +82,15 @@ function rowKey(item: UserListItem) {
|
|||||||
<span><RoleBadge :role="userRole(item)" /></span>
|
<span><RoleBadge :role="userRole(item)" /></span>
|
||||||
<span><InviteStatusBadge :status="item.status" /></span>
|
<span><InviteStatusBadge :status="item.status" /></span>
|
||||||
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
|
||||||
<span></span>
|
<span class="flex justify-end">
|
||||||
|
<UserRowAction
|
||||||
|
v-if="canManage"
|
||||||
|
:item="item"
|
||||||
|
:busy="busyKeys?.includes(rowKey(item))"
|
||||||
|
@cancel="emit('cancel', item)"
|
||||||
|
@renew="emit('renew', item)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,5 +58,13 @@ export function useUsers() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { listUsers, inviteUser }
|
function cancelInvite(id: number): Promise<void> {
|
||||||
|
return request<void>(`/v1/user/invite/${id}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function renewInvite(id: number): Promise<CreatedInvite> {
|
||||||
|
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listUsers, inviteUser, cancelInvite, renewInvite }
|
||||||
}
|
}
|
||||||
+101
-2
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import { useToast } from '@nuxt/ui/composables'
|
import { useOverlay, useToast } from '@nuxt/ui/composables'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import DashboardShell from '@/components/DashboardShell.vue'
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
import InviteUserModal from '@/components/InviteUserModal.vue'
|
import InviteUserModal from '@/components/InviteUserModal.vue'
|
||||||
import UserCardList from '@/components/UserCardList.vue'
|
import UserCardList from '@/components/UserCardList.vue'
|
||||||
import UsersTable from '@/components/UsersTable.vue'
|
import UsersTable from '@/components/UsersTable.vue'
|
||||||
|
import { ApiError } from '@/composables/useApi'
|
||||||
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
|
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
@@ -14,9 +16,13 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
|
|||||||
|
|
||||||
type LoadState = 'loading' | 'ready' | 'error'
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||||
|
const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const overlay = useOverlay()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const { listUsers } = useUsers()
|
const { listUsers, cancelInvite, renewInvite } = useUsers()
|
||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
|
|
||||||
const loadState = ref<LoadState>('loading')
|
const loadState = ref<LoadState>('loading')
|
||||||
@@ -24,6 +30,7 @@ const items = ref<UserListItem[]>([])
|
|||||||
const showSkeleton = ref(false)
|
const showSkeleton = ref(false)
|
||||||
const inviteModalOpen = ref(false)
|
const inviteModalOpen = ref(false)
|
||||||
const highlightedKey = ref('')
|
const highlightedKey = ref('')
|
||||||
|
const busyKeys = ref<string[]>([])
|
||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let opener: HTMLElement | null = null
|
let opener: HTMLElement | null = null
|
||||||
|
|
||||||
@@ -107,6 +114,90 @@ function onInvited(invite: CreatedInvite) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHandledByApiClient(err: unknown) {
|
||||||
|
return err instanceof ApiError && AUTH_HANDLED_STATUSES.includes(err.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(key: string) {
|
||||||
|
items.value = items.value.filter((item) => userRowKey(item.kind, item.id) !== key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCancel(invite: UserListItem) {
|
||||||
|
const key = userRowKey(invite.kind, invite.id)
|
||||||
|
overlay
|
||||||
|
.create(ConfirmDialog, {
|
||||||
|
props: {
|
||||||
|
title: 'Cancel this invite?',
|
||||||
|
description: `The link sent to ${invite.email} will stop working. You can invite the address again later.`,
|
||||||
|
confirmLabel: 'Cancel invite',
|
||||||
|
cancelLabel: 'Keep invite',
|
||||||
|
pendingLabel: 'Cancelling…',
|
||||||
|
tone: 'danger',
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await cancelInvite(invite.id)
|
||||||
|
} catch (err) {
|
||||||
|
if (isHandledByApiClient(err)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(CANCEL_FAILED_COPY)
|
||||||
|
}
|
||||||
|
removeRow(key)
|
||||||
|
toast.add({
|
||||||
|
id: `invite-cancelled-${invite.id}`,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title: 'Invite cancelled',
|
||||||
|
description: `${invite.email} can no longer use the link.`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renew(invite: UserListItem) {
|
||||||
|
const key = userRowKey(invite.kind, invite.id)
|
||||||
|
if (busyKeys.value.includes(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busyKeys.value = [...busyKeys.value, key]
|
||||||
|
try {
|
||||||
|
const renewed = await renewInvite(invite.id)
|
||||||
|
items.value = [
|
||||||
|
{
|
||||||
|
kind: 'invite',
|
||||||
|
id: renewed.id,
|
||||||
|
email: renewed.email,
|
||||||
|
display_name: invite.display_name,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: renewed.created_at,
|
||||||
|
},
|
||||||
|
...items.value.filter((item) => userRowKey(item.kind, item.id) !== key),
|
||||||
|
]
|
||||||
|
highlightedKey.value = userRowKey('invite', renewed.id)
|
||||||
|
toast.add({
|
||||||
|
id: `invite-renewed-${renewed.id}`,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title: 'Invite renewed',
|
||||||
|
description: `${renewed.email} has 3 days to accept the new link.`,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (!isHandledByApiClient(err)) {
|
||||||
|
toast.add({
|
||||||
|
id: `invite-renew-failed-${invite.id}`,
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: "Couldn't renew the invite",
|
||||||
|
description: 'Try again in a moment.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busyKeys.value = busyKeys.value.filter((busyKey) => busyKey !== key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadUsers)
|
onMounted(loadUsers)
|
||||||
onBeforeUnmount(clearSkeletonTimer)
|
onBeforeUnmount(clearSkeletonTimer)
|
||||||
</script>
|
</script>
|
||||||
@@ -162,6 +253,10 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
:loading="loadState === 'loading'"
|
:loading="loadState === 'loading'"
|
||||||
:session-user-id="sessionUserId"
|
:session-user-id="sessionUserId"
|
||||||
:highlighted-key="highlightedKey"
|
:highlighted-key="highlightedKey"
|
||||||
|
:can-manage="canInvite"
|
||||||
|
:busy-keys="busyKeys"
|
||||||
|
@cancel="confirmCancel"
|
||||||
|
@renew="renew"
|
||||||
/>
|
/>
|
||||||
<UserCardList
|
<UserCardList
|
||||||
v-else
|
v-else
|
||||||
@@ -169,6 +264,10 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
:loading="loadState === 'loading'"
|
:loading="loadState === 'loading'"
|
||||||
:session-user-id="sessionUserId"
|
:session-user-id="sessionUserId"
|
||||||
:highlighted-key="highlightedKey"
|
:highlighted-key="highlightedKey"
|
||||||
|
:can-manage="canInvite"
|
||||||
|
:busy-keys="busyKeys"
|
||||||
|
@cancel="confirmCancel"
|
||||||
|
@renew="renew"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import UButton from '@nuxt/ui/components/Button.vue'
|
||||||
|
import UApp from '@nuxt/ui/components/App.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||||
|
import type { UserListItem } from '@/composables/useUsers'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import UsersView from '../UsersView.vue'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useApi: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let toastAdd: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useToast: () => ({ add: toastAdd }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const LIST_PATH = '/v1/user/'
|
||||||
|
const CANCEL_PATH = '/v1/user/invite/2'
|
||||||
|
const RENEW_PATH = '/v1/user/invite/1/renew'
|
||||||
|
const CANCEL_NOTICE = "Couldn't cancel the invite. Try again in a moment."
|
||||||
|
const CANCEL_DESCRIPTION =
|
||||||
|
'The link sent to sam.oliveira@renew.example will stop working. You can invite the address again later.'
|
||||||
|
|
||||||
|
function defaultFixture(): UserListItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: 'invite',
|
||||||
|
id: 2,
|
||||||
|
email: 'sam.oliveira@renew.example',
|
||||||
|
status: 'pending',
|
||||||
|
created_at: '2026-09-27T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'invite',
|
||||||
|
id: 1,
|
||||||
|
email: 'p.huber@gmail.com',
|
||||||
|
display_name: 'Paul Huber',
|
||||||
|
status: 'expired',
|
||||||
|
created_at: '2026-08-03T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'user',
|
||||||
|
id: 3,
|
||||||
|
username: 'ines',
|
||||||
|
email: 'ines.duarte@renew.example',
|
||||||
|
role: 'admin',
|
||||||
|
display_name: 'Inès Duarte',
|
||||||
|
status: 'accepted',
|
||||||
|
created_at: '2026-06-18T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'user',
|
||||||
|
id: 2,
|
||||||
|
username: 'jonas',
|
||||||
|
email: 'jonas@renew.example',
|
||||||
|
role: 'admin',
|
||||||
|
display_name: '',
|
||||||
|
status: 'accepted',
|
||||||
|
created_at: '2026-04-02T09:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'user',
|
||||||
|
id: 1,
|
||||||
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
role: 'owner',
|
||||||
|
display_name: '',
|
||||||
|
status: 'accepted',
|
||||||
|
created_at: '2026-03-12T09:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoExpiredFixture(): UserListItem[] {
|
||||||
|
const [first, second, ...rest] = defaultFixture()
|
||||||
|
return [
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
{
|
||||||
|
kind: 'invite',
|
||||||
|
id: 7,
|
||||||
|
email: 'old@renew.example',
|
||||||
|
status: 'expired',
|
||||||
|
created_at: '2026-07-01T09:00:00Z',
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renewedResponse(id = 1, email = 'p.huber@gmail.com') {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'invite',
|
||||||
|
email,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>((res) => {
|
||||||
|
resolve = res
|
||||||
|
})
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(desktop: boolean) {
|
||||||
|
window.matchMedia = ((query: string) => ({
|
||||||
|
matches: desktop,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
addListener: () => undefined,
|
||||||
|
removeListener: () => undefined,
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
})) as unknown as typeof window.matchMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestMock: ReturnType<typeof vi.fn>
|
||||||
|
let wrapper: VueWrapper | null = null
|
||||||
|
|
||||||
|
interface Handlers {
|
||||||
|
fixture?: UserListItem[]
|
||||||
|
cancel?: () => Promise<unknown>
|
||||||
|
renew?: (path: string) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mountPage(
|
||||||
|
{ fixture, cancel, renew }: Handlers = {},
|
||||||
|
desktop = true,
|
||||||
|
role: string | null = 'owner',
|
||||||
|
) {
|
||||||
|
setViewport(desktop)
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
const sessionRole = role ?? undefined
|
||||||
|
session.session = {
|
||||||
|
verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
id: 1,
|
||||||
|
displayName: '',
|
||||||
|
role: sessionRole,
|
||||||
|
}
|
||||||
|
session.status = 'ready'
|
||||||
|
requestMock = vi.fn((path: string, options?: RequestInit) => {
|
||||||
|
if (path === LIST_PATH) {
|
||||||
|
return Promise.resolve(fixture ?? defaultFixture())
|
||||||
|
}
|
||||||
|
if (options?.method === 'DELETE') {
|
||||||
|
return (cancel ?? (() => Promise.resolve(undefined)))()
|
||||||
|
}
|
||||||
|
if (options?.method === 'POST' && path === '/v1/user/invite') {
|
||||||
|
return Promise.resolve(renewedResponse(3, 'lea.martin@renew.example'))
|
||||||
|
}
|
||||||
|
if (options?.method === 'POST' && path.endsWith('/renew')) {
|
||||||
|
return (renew ?? (() => Promise.resolve(renewedResponse())))(path)
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected request: ${path}`)
|
||||||
|
})
|
||||||
|
vi.mocked(useApi).mockReturnValue({
|
||||||
|
getMe: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
|
||||||
|
request: requestMock,
|
||||||
|
})
|
||||||
|
await router.push('/users')
|
||||||
|
const Host = defineComponent({ render: () => h(UApp, null, () => h(UsersView)) })
|
||||||
|
wrapper = mount(Host, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows() {
|
||||||
|
return Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-testid="user-row"], [data-testid="user-card"]',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOf(email: string) {
|
||||||
|
return rows().find((row) => row.textContent?.includes(email))!
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonsIn(root: ParentNode) {
|
||||||
|
return Array.from(root.querySelectorAll('button'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonIn(root: ParentNode, text: string) {
|
||||||
|
return buttonsIn(root).find((button) => button.textContent?.trim() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog() {
|
||||||
|
return document.body.querySelector('[role="alertdialog"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogButton(text: string) {
|
||||||
|
return buttonIn(dialog()!, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function click(button: HTMLElement | undefined) {
|
||||||
|
button?.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodCalls(method: string) {
|
||||||
|
return requestMock.mock.calls.filter(([, options]) => options?.method === method)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCalls() {
|
||||||
|
return requestMock.mock.calls.filter(([path]) => path === LIST_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCancelDialog() {
|
||||||
|
await click(buttonIn(rowOf('sam.oliveira@renew.example'), 'Cancel invite'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCancel() {
|
||||||
|
await click(dialogButton('Cancel invite'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconOf(button: HTMLElement) {
|
||||||
|
return wrapper!
|
||||||
|
.findAllComponents(UButton)
|
||||||
|
.find((component) => component.element === button)
|
||||||
|
?.props('icon')
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toastAdd = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{ name: 'desktop', desktop: true },
|
||||||
|
{ name: 'below md', desktop: false },
|
||||||
|
])(
|
||||||
|
'Default: actions sit on the right of the Pending and Expired rows only ($name)',
|
||||||
|
({ desktop }) => {
|
||||||
|
it('shows Cancel invite, Renew invite and nothing on Accepted rows', async () => {
|
||||||
|
await mountPage({}, desktop)
|
||||||
|
|
||||||
|
const [first, second, ...accepted] = rows()
|
||||||
|
const cancel = buttonIn(first, 'Cancel invite')!
|
||||||
|
const renew = buttonIn(second, 'Renew invite')!
|
||||||
|
expect(iconOf(cancel)).toBe('i-lucide-user-x')
|
||||||
|
expect(cancel.className).toContain('text-error')
|
||||||
|
expect(iconOf(renew)).toBe('i-lucide-refresh-cw')
|
||||||
|
expect(renew.className).toContain('text-highlighted')
|
||||||
|
expect(accepted).toHaveLength(3)
|
||||||
|
accepted.forEach((row) => expect(buttonsIn(row)).toHaveLength(0))
|
||||||
|
expect(cancel.className).toContain(desktop ? 'h-7' : 'h-11')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{ name: 'desktop', desktop: true },
|
||||||
|
{ name: 'below md', desktop: false },
|
||||||
|
])('Non-owners see no row actions ($name)', ({ desktop }) => {
|
||||||
|
it.each([
|
||||||
|
{ role: 'admin', present: false },
|
||||||
|
{ role: null, present: false },
|
||||||
|
{ role: 'owner', present: true },
|
||||||
|
])('role $role shows the actions: $present', async ({ role, present }) => {
|
||||||
|
await mountPage({}, desktop, role)
|
||||||
|
|
||||||
|
const labels = Array.from(document.body.querySelectorAll('button')).map((button) =>
|
||||||
|
button.textContent?.trim(),
|
||||||
|
)
|
||||||
|
expect(labels.filter((label) => label === 'Cancel invite')).toHaveLength(present ? 1 : 0)
|
||||||
|
expect(labels.filter((label) => label === 'Renew invite')).toHaveLength(present ? 1 : 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Invite-only rule', () => {
|
||||||
|
it('offers no Renew on a Pending row and no Cancel on an Expired row', async () => {
|
||||||
|
await mountPage()
|
||||||
|
|
||||||
|
expect(buttonIn(rowOf('sam.oliveira@renew.example'), 'Renew invite')).toBeUndefined()
|
||||||
|
expect(buttonIn(rowOf('p.huber@gmail.com'), 'Cancel invite')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: confirm', () => {
|
||||||
|
it('opens the danger dialog and sends nothing', async () => {
|
||||||
|
await mountPage()
|
||||||
|
await openCancelDialog()
|
||||||
|
|
||||||
|
expect(dialog()?.textContent).toContain('Cancel this invite?')
|
||||||
|
expect(dialog()?.textContent).toContain(CANCEL_DESCRIPTION)
|
||||||
|
expect(dialogButton('Keep invite')).toBeDefined()
|
||||||
|
expect(dialogButton('Cancel invite')).toBeDefined()
|
||||||
|
expect(methodCalls('DELETE')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: Keep invite', () => {
|
||||||
|
it('closes the dialog and changes nothing', async () => {
|
||||||
|
await mountPage()
|
||||||
|
await openCancelDialog()
|
||||||
|
await click(dialogButton('Keep invite'))
|
||||||
|
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
expect(methodCalls('DELETE')).toHaveLength(0)
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: sending', () => {
|
||||||
|
it('sends once, shows Cancelling and locks the dialog', async () => {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
await mountPage({ cancel: () => pending.promise })
|
||||||
|
await openCancelDialog()
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(methodCalls('DELETE')).toHaveLength(1)
|
||||||
|
expect(methodCalls('DELETE')[0][0]).toBe(CANCEL_PATH)
|
||||||
|
expect(methodCalls('DELETE')[0][1].body).toBeUndefined()
|
||||||
|
expect(dialogButton('Cancelling…')).toBeDefined()
|
||||||
|
expect(dialogButton('Keep invite')!.disabled).toBe(true)
|
||||||
|
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
code: 'Escape',
|
||||||
|
keyCode: 27,
|
||||||
|
which: 27,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: failed', () => {
|
||||||
|
it.each([
|
||||||
|
{ name: 'a 500 response', failure: () => new ApiError(500) },
|
||||||
|
{
|
||||||
|
name: 'a 500 with other text',
|
||||||
|
failure: () => new ApiError(500, undefined, 'db is down'),
|
||||||
|
},
|
||||||
|
{ name: 'a 404 response', failure: () => new ApiError(404) },
|
||||||
|
{ name: 'a network failure', failure: () => new NetworkError() },
|
||||||
|
])('keeps the dialog open with the notice on $name', async ({ failure }) => {
|
||||||
|
await mountPage({ cancel: () => Promise.reject(failure()) })
|
||||||
|
await openCancelDialog()
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
expect(dialog()!.textContent).toContain(CANCEL_NOTICE)
|
||||||
|
expect(dialog()!.textContent).not.toContain('db is down')
|
||||||
|
expect(dialog()!.querySelectorAll('[role="alert"]')).toHaveLength(1)
|
||||||
|
expect(dialogButton('Keep invite')!.disabled).toBe(false)
|
||||||
|
expect(dialogButton('Cancel invite')!.disabled).toBe(false)
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
expect(toastAdd).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: retrying after a failure', () => {
|
||||||
|
it('closes and removes the row on the second attempt', async () => {
|
||||||
|
const outcomes = [() => Promise.reject(new ApiError(500)), () => Promise.resolve(undefined)]
|
||||||
|
await mountPage({ cancel: () => outcomes.shift()!() })
|
||||||
|
await openCancelDialog()
|
||||||
|
await confirmCancel()
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(methodCalls('DELETE')).toHaveLength(2)
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancel invite: done', () => {
|
||||||
|
it('removes the row, keeps the order, skips the reload and raises the toast', async () => {
|
||||||
|
await mountPage()
|
||||||
|
await openCancelDialog()
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
expect(rows()[0].textContent).toContain('p.huber@gmail.com')
|
||||||
|
expect(rows().some((row) => row.textContent?.includes('sam.oliveira'))).toBe(false)
|
||||||
|
expect(listCalls()).toHaveLength(1)
|
||||||
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
|
id: 'invite-cancelled-2',
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title: 'Invite cancelled',
|
||||||
|
description: 'sam.oliveira@renew.example can no longer use the link.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('401 and 403 are left to the API client', () => {
|
||||||
|
it.each([401, 403])('cancel with %i adds no notice and no toast', async (status) => {
|
||||||
|
await mountPage({ cancel: () => Promise.reject(new ApiError(status)) })
|
||||||
|
await openCancelDialog()
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(document.body.textContent).not.toContain(CANCEL_NOTICE)
|
||||||
|
expect(toastAdd).not.toHaveBeenCalled()
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([401, 403])('renew with %i adds no toast', async (status) => {
|
||||||
|
await mountPage({ renew: () => Promise.reject(new ApiError(status)) })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
expect(toastAdd).not.toHaveBeenCalled()
|
||||||
|
expect(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite')!.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Renew invite: sending', () => {
|
||||||
|
it('sends at once, marks only that button busy and ignores a second click', async () => {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
await mountPage({ renew: () => pending.promise })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
expect(methodCalls('POST')).toHaveLength(1)
|
||||||
|
expect(methodCalls('POST')[0][0]).toBe(RENEW_PATH)
|
||||||
|
expect(methodCalls('POST')[0][1].body).toBeUndefined()
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
const busy = buttonIn(rowOf('p.huber@gmail.com'), 'Renewing…')!
|
||||||
|
expect(busy.disabled).toBe(true)
|
||||||
|
expect(iconOf(busy)).toBe('i-lucide-loader-circle')
|
||||||
|
expect(busy.className).toContain('opacity-50')
|
||||||
|
expect(buttonIn(rowOf('sam.oliveira@renew.example'), 'Cancel invite')!.disabled).toBe(false)
|
||||||
|
|
||||||
|
await click(busy)
|
||||||
|
expect(methodCalls('POST')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves another expired row usable', async () => {
|
||||||
|
const pending = deferred<unknown>()
|
||||||
|
await mountPage({ fixture: twoExpiredFixture(), renew: () => pending.promise })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
const other = buttonIn(rowOf('old@renew.example'), 'Renew invite')!
|
||||||
|
expect(other.disabled).toBe(false)
|
||||||
|
await click(other)
|
||||||
|
expect(methodCalls('POST')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Renew invite: done', () => {
|
||||||
|
it('moves the row to the top as Pending and raises the toast', async () => {
|
||||||
|
await mountPage()
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
const [first, second] = rows()
|
||||||
|
expect(first.textContent).toContain('p.huber@gmail.com')
|
||||||
|
expect(first.textContent).toContain('Pending')
|
||||||
|
expect(first.textContent).toContain('Paul Huber')
|
||||||
|
expect(first.className).toContain('bg-elevated')
|
||||||
|
expect(buttonIn(first, 'Cancel invite')).toBeDefined()
|
||||||
|
expect(second.textContent).toContain('sam.oliveira@renew.example')
|
||||||
|
expect(rows().filter((row) => row.textContent?.includes('p.huber@gmail.com'))).toHaveLength(
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
expect(listCalls()).toHaveLength(1)
|
||||||
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
|
id: 'invite-renewed-1',
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title: 'Invite renewed',
|
||||||
|
description: 'p.huber@gmail.com has 3 days to accept the new link.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Renew invite: failed', () => {
|
||||||
|
it.each([
|
||||||
|
{ name: 'a 500 response', failure: () => new ApiError(500) },
|
||||||
|
{ name: 'a 500 with other text', failure: () => new ApiError(500, undefined, 'smtp down') },
|
||||||
|
{ name: 'a 404 response', failure: () => new ApiError(404) },
|
||||||
|
{ name: 'a network failure', failure: () => new NetworkError() },
|
||||||
|
])('raises the error toast and keeps the row on $name', async ({ failure }) => {
|
||||||
|
await mountPage({ renew: () => Promise.reject(failure()) })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
|
id: 'invite-renew-failed-1',
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: "Couldn't renew the invite",
|
||||||
|
description: 'Try again in a moment.',
|
||||||
|
})
|
||||||
|
expect(rows()[1].textContent).toContain('p.huber@gmail.com')
|
||||||
|
expect(rows()[1].textContent).toContain('Expired')
|
||||||
|
expect(buttonIn(rows()[1], 'Renew invite')!.disabled).toBe(false)
|
||||||
|
expect(rows()).toHaveLength(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Renew invite: retrying after a failure', () => {
|
||||||
|
it('raises the success toast and puts the row on top', async () => {
|
||||||
|
const outcomes = [
|
||||||
|
() => Promise.reject(new ApiError(500)),
|
||||||
|
() => Promise.resolve(renewedResponse()),
|
||||||
|
]
|
||||||
|
await mountPage({ renew: () => outcomes.shift()!() })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
|
||||||
|
expect(toastAdd).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ id: 'invite-renewed-1' }),
|
||||||
|
)
|
||||||
|
expect(rows()[0].textContent).toContain('p.huber@gmail.com')
|
||||||
|
expect(rows()[0].textContent).toContain('Pending')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cancelling a just-renewed row', () => {
|
||||||
|
it('removes it and raises Invite cancelled', async () => {
|
||||||
|
await mountPage({ cancel: () => Promise.resolve(undefined) })
|
||||||
|
await click(buttonIn(rowOf('p.huber@gmail.com'), 'Renew invite'))
|
||||||
|
await click(buttonIn(rows()[0], 'Cancel invite'))
|
||||||
|
await confirmCancel()
|
||||||
|
|
||||||
|
expect(methodCalls('DELETE')[0][0]).toBe('/v1/user/invite/1')
|
||||||
|
expect(rows()).toHaveLength(4)
|
||||||
|
expect(toastAdd).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ id: 'invite-cancelled-1', title: 'Invite cancelled' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Actions after a new invite is sent', () => {
|
||||||
|
it('ends the new first row with Cancel invite', async () => {
|
||||||
|
await mountPage()
|
||||||
|
await click(buttonIn(document.body, 'Invite user'))
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'input[placeholder="name@example.com"]',
|
||||||
|
)!
|
||||||
|
input.value = 'lea.martin@renew.example'
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await flushPromises()
|
||||||
|
await click(buttonIn(document.body, 'Send invite'))
|
||||||
|
|
||||||
|
expect(rows()[0].textContent).toContain('lea.martin@renew.example')
|
||||||
|
expect(buttonIn(rows()[0], 'Cancel invite')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -279,8 +279,8 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
|||||||
expect(findButton('Invite user')).toBeTruthy()
|
expect(findButton('Invite user')).toBeTruthy()
|
||||||
|
|
||||||
expect(rowTexts()).toEqual([
|
expect(rowTexts()).toEqual([
|
||||||
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
|
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
|
||||||
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
|
||||||
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||||
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||||
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||||
@@ -338,8 +338,8 @@ describe('Default — the list loads newest first, with display name, "you" tag
|
|||||||
await mountPage({}, false)
|
await mountPage({}, false)
|
||||||
|
|
||||||
expect(rowTexts()).toEqual([
|
expect(rowTexts()).toEqual([
|
||||||
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
|
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026 Cancel invite',
|
||||||
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026 Renew invite',
|
||||||
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
|
||||||
'jonas@renew.example admin Accepted Created 2 Apr 2026',
|
'jonas@renew.example admin Accepted Created 2 Apr 2026',
|
||||||
'marta@renew.example you owner Accepted Created 12 Mar 2026',
|
'marta@renew.example you owner Accepted Created 12 Mar 2026',
|
||||||
@@ -457,13 +457,20 @@ describe('Non-owners cannot invite', () => {
|
|||||||
expect(findButton('Invite user')).toBeUndefined()
|
expect(findButton('Invite user')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([true, false])('shows no hint for an admin on marta and jonas alone (desktop %s)', async (desktop) => {
|
it.each([true, false])(
|
||||||
await mountPage({ list: () => Promise.resolve(defaultFixture().slice(3)) }, desktop, 'admin')
|
'shows no hint for an admin on marta and jonas alone (desktop %s)',
|
||||||
|
async (desktop) => {
|
||||||
|
await mountPage(
|
||||||
|
{ list: () => Promise.resolve(defaultFixture().slice(3)) },
|
||||||
|
desktop,
|
||||||
|
'admin',
|
||||||
|
)
|
||||||
|
|
||||||
expect(rows()).toHaveLength(2)
|
expect(rows()).toHaveLength(2)
|
||||||
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
|
||||||
expect(findButton('Invite user')).toBeUndefined()
|
expect(findButton('Invite user')).toBeUndefined()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
|
||||||
@@ -598,8 +605,8 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
|
|||||||
expect(texts).toHaveLength(6)
|
expect(texts).toHaveLength(6)
|
||||||
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
|
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
|
||||||
expect(texts.slice(1)).toEqual([
|
expect(texts.slice(1)).toEqual([
|
||||||
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
|
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
|
||||||
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
|
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026 Renew invite',
|
||||||
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
|
||||||
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
'jonas@renew.example — admin Accepted 2 Apr 2026',
|
||||||
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
'marta@renew.example you — owner Accepted 12 Mar 2026',
|
||||||
|
|||||||
+3
-1
@@ -88,7 +88,9 @@ async function readScreen(name: string): Promise<Screen> {
|
|||||||
if (!reference) {
|
if (!reference) {
|
||||||
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
||||||
}
|
}
|
||||||
const states = [...markdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
|
const statesStart = markdown.search(/^## States$/m)
|
||||||
|
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||||
|
const states = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
|
||||||
return { name, board: reference[1]!, group: reference[2]!, states }
|
return { name, board: reference[1]!, group: reference[2]!, states }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-4
@@ -634,6 +634,33 @@ async function openInviteModal(page: Page) {
|
|||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowActionButton(page: Page, email: string, name: string) {
|
||||||
|
return page
|
||||||
|
.locator('[data-testid="user-row"], [data-testid="user-card"]')
|
||||||
|
.filter({ hasText: email })
|
||||||
|
.getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCancelInviteDialog(page: Page) {
|
||||||
|
await rowActionButton(page, 'sam.oliveira@renew.example', 'Cancel invite').click()
|
||||||
|
await page.getByRole('alertdialog').waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCancelInvite(page: Page) {
|
||||||
|
await openCancelInviteDialog(page)
|
||||||
|
await page
|
||||||
|
.getByRole('alertdialog')
|
||||||
|
.getByRole('button', { name: /^Cancel(ling…| invite)$/ })
|
||||||
|
.click()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renewPaulHuberInvite(page: Page) {
|
||||||
|
await rowActionButton(page, 'p.huber@gmail.com', 'Renew invite').click()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
function inviteWith(email: string, response?: ApiMock) {
|
function inviteWith(email: string, response?: ApiMock) {
|
||||||
return async (page: Page) => {
|
return async (page: Page) => {
|
||||||
await openInviteModal(page)
|
await openInviteModal(page)
|
||||||
@@ -845,10 +872,40 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
Default: {
|
Default: {
|
||||||
path: '/users',
|
path: '/users',
|
||||||
api: usersApi(),
|
api: usersApi(),
|
||||||
exception: {
|
},
|
||||||
maxPercent: 10.5,
|
'Cancel invite: confirm': {
|
||||||
reason: 'Row actions (Cancel invite, Renew invite) drawn on the board are out of scope for this slice',
|
path: '/users',
|
||||||
approvedBy: 'user',
|
api: usersApi(),
|
||||||
|
steps: openCancelInviteDialog,
|
||||||
|
},
|
||||||
|
'Cancel invite: sending': {
|
||||||
|
path: '/users',
|
||||||
|
api: usersApi({ 'DELETE /v1/user/invite/2': { status: 204, hang: true } }),
|
||||||
|
steps: confirmCancelInvite,
|
||||||
|
},
|
||||||
|
'Cancel invite: failed': {
|
||||||
|
path: '/users',
|
||||||
|
api: usersApi({ 'DELETE /v1/user/invite/2': { status: 500 } }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await confirmCancelInvite(page)
|
||||||
|
await page.getByText("Couldn't cancel the invite").waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Cancel invite: done': { path: '/users', skip: 'clock-sensitive' },
|
||||||
|
'Renew invite: sending': {
|
||||||
|
path: '/users',
|
||||||
|
api: usersApi({ 'POST /v1/user/invite/1/renew': { status: 200, hang: true } }),
|
||||||
|
steps: renewPaulHuberInvite,
|
||||||
|
},
|
||||||
|
'Renew invite: done': { path: '/users', skip: 'clock-sensitive' },
|
||||||
|
'Renew invite: failed': {
|
||||||
|
path: '/users',
|
||||||
|
api: usersApi({ 'POST /v1/user/invite/1/renew': { status: 500 } }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await renewPaulHuberInvite(page)
|
||||||
|
await page.getByText("Couldn't renew the invite").waitFor()
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Loading: {
|
Loading: {
|
||||||
|
|||||||
Reference in new issue
Block a user