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

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

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
Feature: Users row actions: Cancel invite and Renew invite
As the owner of a RenewCMS workspace
I want to cancel a pending invite and renew an expired one from the Users list
So that I can correct a wrong address or resend a lapsed link without leaving the page
Background:
Given /users is the verified route of features/users_screen.feature, rendered as UsersPage with
UsersTable (md and up) or UserCardList (below md), per docs/design/screens/users.md
And the backend already exists (features/invite_management.feature) and this slice changes no Go:
DELETE /v1/user/invite/{id} answers 204 with an empty body, and POST /v1/user/invite/{id}/renew
answers 200 with {id, kind "invite", email, status "pending", created_at, expires_at}
And useUsers gains cancelInvite(id), which sends DELETE /v1/user/invite/{id} with no body, and
renewInvite(id), which sends POST /v1/user/invite/{id}/renew with no body
And the actions are shown only when the session user's role from GET /v1/auth/me is "owner";
for "admin", or any other or absent value, no action button is rendered and the sixth column
stays empty; this is not drawn in docs/design, so it has no visual state and is covered
by Vitest only
And the sixth column (desktop) or the last line of the card (below md) holds at most one
action: "Cancel invite" on a Pending row, "Renew invite" on an Expired row, nothing on an
Accepted row
And each action is an outline button: 28 px high on desktop (12 px medium text, 13 px icon,
9 px side padding, --ui-border-accented border, --ui-bg fill, no wrapping) and 44 px high
below md (14 px side padding, 14 px icon); UButton variant "outline" size "xs"
And "Cancel invite" has the icon user-x and text in --ui-error; "Renew invite" has the icon
refresh-cw and text --ui-text-highlighted
And below md the action is the last line of the card, under the badges line
And "Cancel invite" opens ConfirmDialog (tone "danger") through useOverlay: title "Cancel this
invite?", description "The link sent to {email} will stop working. You can invite the address
again later.", cancel button "Keep invite", confirm button "Cancel invite", pending label
"Cancelling…"; the action runs inside the dialog, so the failure notice shows in the dialog
And "Renew invite" asks nothing and sends the request at once
And a request failure is any 500, any network failure that never reaches the server, and any
other status except 401 and 403; 401 and 403 are handled entirely by useApi's global handling
(features/api_client_global_error_handling.feature); the response body text is never shown
verbatim
And the fixture is the Default fixture of features/users_screen.feature: invite id 2
"sam.oliveira@renew.example" pending 27 Sep 2026; invite id 1 "p.huber@gmail.com" with
display_name "Paul Huber" expired 3 Aug 2026; users 3, 2, 1; the session user is "marta",
role "owner"; rows are keyed by kind and id
Scenario: Default: actions sit on the right of the Pending and Expired rows only
Given GET /v1/user/ resolves 200 with the Default fixture
When the owner navigates to /users at desktop
Then the first row "sam.oliveira@renew.example" ends with "Cancel invite" (icon user-x, text --ui-error)
And the second row "p.huber@gmail.com" ends with "Renew invite" (icon refresh-cw)
And the three Accepted rows have an empty sixth cell
And below md the same two cards end with the same buttons as 44 px buttons
Scenario Outline: Non-owners see no row actions
Given the session user has role "<role>" and GET /v1/user/ resolves 200 with the Default fixture
When the user navigates to /users at desktop and at a below-md width
Then <result>
Examples:
| role | result |
| admin | no "Cancel invite" and no "Renew invite" button exists |
| (none) | no "Cancel invite" and no "Renew invite" button exists |
| owner | "Cancel invite" and "Renew invite" are present once each |
Scenario: Cancel invite: confirm
Given the Default state has loaded
When the owner activates "Cancel invite" on the "sam.oliveira@renew.example" row
Then a danger modal opens titled "Cancel this invite?"
And its text reads "The link sent to sam.oliveira@renew.example will stop working. You can invite
the address again later."
And its buttons are "Keep invite" and "Cancel invite" (danger), and no request has been sent
Scenario: Cancel invite: "Keep invite" closes the dialog and changes nothing
Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example"
When the owner activates "Keep invite"
Then the dialog closes, DELETE /v1/user/invite/2 is never sent and all 5 rows remain
Scenario: Cancel invite: sending
Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example"
When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 has not resolved
Then DELETE /v1/user/invite/2 was sent once
And the confirm button reads "Cancelling…" with a loading indicator
And "Keep invite" is disabled
And Esc and a backdrop click do not close the dialog
Scenario Outline: Cancel invite: failed
Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example"
When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 <failure>
Then the dialog stays open, with an error notice inside it reading "Couldn't cancel the invite.
Try again in a moment."
And "Keep invite" and "Cancel invite" are enabled again
And all 5 rows remain and no toast is raised
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 500 response whose body is some other text |
| never reaches the server |
| resolves with a 404 response |
Scenario: Cancel invite: retrying after a failure
Given the dialog shows the failure notice
When the owner activates "Cancel invite" again and DELETE /v1/user/invite/2 resolves 204
Then DELETE /v1/user/invite/2 was sent a second time
And the dialog closes and the row is removed
Scenario: Cancel invite: done
Given the "Cancel this invite?" dialog is open for "sam.oliveira@renew.example"
When the owner activates "Cancel invite" and DELETE /v1/user/invite/2 resolves 204
Then the dialog closes
And the row "sam.oliveira@renew.example" is gone and the other 4 rows remain in their order
And GET /v1/user/ is not called again
And a toast appears: color success, icon i-lucide-circle-check, id "invite-cancelled-2", title
"Invite cancelled", description "sam.oliveira@renew.example can no longer use the link."
Scenario: Cancel invite: an Expired row can't be cancelled from the list
Given the Default state has loaded
Then the "p.huber@gmail.com" row offers "Renew invite" and no "Cancel invite"
Scenario: Renew invite: sending
Given the Default state has loaded
When the owner activates "Renew invite" on the "p.huber@gmail.com" row and
POST /v1/user/invite/1/renew has not resolved
Then POST /v1/user/invite/1/renew was sent once with no body and no confirmation was shown
And on that row the button reads "Renewing…" with the icon loader-circle at 50 % opacity, disabled
And no other row's button changes and the "Cancel invite" button of "sam.oliveira@renew.example" stays enabled
And a second activation of the busy button sends nothing
Scenario: Renew invite: done
Given POST /v1/user/invite/1/renew was sent from the "p.huber@gmail.com" row
When it resolves 200 with id 1, kind "invite", email "p.huber@gmail.com", status "pending",
created_at now and expires_at 3 days later
Then the row moves to the top of the list as Pending, created "Today", drawn with the
--ui-bg-elevated background, keeping its display name "Paul Huber" and role "admin", and now
ends with "Cancel invite"
And "sam.oliveira@renew.example" is now the second row and the other rows are unchanged
And exactly one row exists for "p.huber@gmail.com" and GET /v1/user/ is not called again
And a toast appears: color success, icon i-lucide-circle-check, id "invite-renewed-1", title
"Invite renewed", description "p.huber@gmail.com has 3 days to accept the new link."
Scenario: Renew invite: renewing a Pending row is not offered
Given the Default state has loaded
Then the "sam.oliveira@renew.example" row offers "Cancel invite" and no "Renew invite"
Scenario Outline: Renew invite: failed
Given the Default state has loaded
When the owner activates "Renew invite" on the "p.huber@gmail.com" row and
POST /v1/user/invite/1/renew <failure>
Then a toast appears: color error, icon i-lucide-circle-x, id "invite-renew-failed-1", title
"Couldn't renew the invite", description "Try again in a moment."
And the row stays Expired in its place, with "Renew invite" enabled again
And the list is otherwise unchanged
Examples:
| failure |
| resolves with a 500 response |
| resolves with a 500 response whose body is some other text |
| never reaches the server |
| resolves with a 404 response |
Scenario: Renew invite: retrying after a failure
Given the failed toast has appeared for "p.huber@gmail.com"
When the owner activates "Renew invite" again and POST /v1/user/invite/1/renew resolves 200
Then the "Invite renewed" toast is raised and the row is Pending on top
Scenario: Renew invite: only the busy row is busy
Given the fixture also holds Expired invite id 7 "old@renew.example"
When the owner activates "Renew invite" on "p.huber@gmail.com" and it has not resolved
Then "Renew invite" on "old@renew.example" is still enabled and can be activated
Scenario: Actions after a new invite is sent
Given the Default state has loaded and an invite for "lea.martin@renew.example" was sent (201)
Then the new first row ends with "Cancel invite"
Scenario: Cancelling a just-renewed row
Given "p.huber@gmail.com" was renewed and is the first row
When the owner confirms "Cancel invite" for it and DELETE /v1/user/invite/1 resolves 204
Then the row is gone and the toast "Invite cancelled" is raised
+98
View File
@@ -0,0 +1,98 @@
# QA: Users row actions (Cancel invite, Renew invite)
## Implementation note for coder (informational, not a QA assertion)
No Go change. Toasts use useToast().add({ id, color, icon, title, description }):
- cancelled: id "invite-cancelled-{id}", color success, icon i-lucide-circle-check, title "Invite cancelled",
description "{email} can no longer use the link."
- renewed: id "invite-renewed-{id}", color success, icon i-lucide-circle-check, title "Invite renewed",
description "{email} has 3 days to accept the new link."
- renew failed: id "invite-renew-failed-{id}", color error, icon i-lucide-circle-x, title "Couldn't renew the invite",
description "Try again in a moment."
Cancel runs through ConfirmDialog (tone "danger", cancelLabel "Keep invite", confirmLabel "Cancel invite",
pendingLabel "Cancelling…"); its action throws an Error whose message is "Couldn't cancel the invite. Try again in
a moment." for any failure except 401/403 (global handling), so the dialog shows it. Remove the 10.5 % exception
from the "users" > Default state in web/visual/states.ts.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock useApi's `request` for GET /v1/user/, DELETE /v1/user/invite/{id} and POST /v1/user/invite/{id}/renew. Mount
UsersPage inside DashboardShell with a verified owner session (id 1, "marta", role "owner") and a test router at
/users. Default fixture as in features/users_screen.qa.md. No test freezes the system date; the renewed row's
"Today" label is not asserted. Run layout cases at desktop and below-md widths (mock matchMedia).
1. Default actions: row 1 has "Cancel invite" (user-x), row 2 "Renew invite" (refresh-cw), Accepted rows have
none; at below-md the same buttons sit last in the cards.
2. Non-owners, Examples-driven: role "admin", role absent, role "owner"; assert presence or absence of both
buttons at both widths.
3. Cancel invite: confirm: click "Cancel invite" on row 1; assert dialog title, text with the address, "Keep
invite", "Cancel invite", and that `request` was not called with DELETE.
4. Keep invite: click it; assert the dialog closed, no DELETE, 5 rows.
5. Cancel invite: sending: unresolved DELETE; assert one call to DELETE /v1/user/invite/2 (no body), "Cancelling…",
"Keep invite" disabled, Escape and backdrop click do not close.
6. Cancel invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
NetworkError; assert the dialog stays open with the notice "Couldn't cancel the invite. Try again in a moment."
once (never the raw body), buttons re-enabled, 5 rows, no toast.
7. Cancel invite: retry: after 6, resolve 204 on the second click; assert two DELETE calls, dialog closed, row gone.
8. Cancel invite: done: resolve 204; assert dialog closed, row 1 gone, 4 rows in order, no second GET, toast
raised with the id, color, icon, title and description above.
9. 401 and 403 on DELETE and on renew: assert the global handling runs and no local notice or toast is added.
10. Renew invite: sending: unresolved POST; assert one call to POST /v1/user/invite/1/renew with no body, no
dialog, the row-2 button reads "Renewing…", is disabled, has the loader-circle icon and 50 % opacity class; row
1's "Cancel invite" is enabled; a second click sends nothing.
11. Only the busy row is busy: two Expired rows; renew one, assert the other button is enabled and usable.
12. Renew invite: done: resolve 200 with the response shape; assert "p.huber@gmail.com" is first, Pending, has the
--ui-bg-elevated class, keeps "Paul Huber", ends with "Cancel invite", "sam.oliveira@renew.example" second, one
row for the address, no second GET, and the toast (id, color, icon, title, description).
13. Renew invite: failed, Examples-driven: ApiError(500), ApiError(500) with other text, ApiError(404),
NetworkError; assert the error toast, the row still Expired in place, the button enabled again with the label
"Renew invite".
14. Renew retry: after 13, resolve 200; assert the success toast and the row on top.
15. Interplay: a row from a fresh invite (201) shows "Cancel invite"; cancelling a just-renewed row removes it and
raises "Invite cancelled".
16. Invite-only rule: assert no "Renew invite" on a Pending row and no "Cancel invite" on an Expired row.
## Visual (task visual:check -- users, desktop and mobile, light and dark)
Reference: docs/design/screens/users.md; board group "Users" in `docs/design/source/RenewCMS Screens
Back-office.dc.html`; exact values in `docs/design/source/AppFrame.dc.html`. Layout (button size, position in the
sixth column and on the card, dialog geometry) is taken from the board frame, not components.md. The gate is hard:
every capture of every non-skipped state must be within 10 %, both viewports, both themes. Use the board's sample
data: the existing `usersFixture` (sam.oliveira@renew.example pending 27 Sep 2026; p.huber@gmail.com "Paul Huber"
expired 3 Aug 2026), session marta/owner. Add these 7 states to `users` in web/visual/states.ts:
- [ ] Cancel invite: confirm: steps click "Cancel invite" on the sam.oliveira row; dialog "Cancel this invite?" with
text naming sam.oliveira@renew.example, "Keep invite", "Cancel invite".
- [ ] Cancel invite: sending: mock `DELETE /v1/user/invite/2` status 204 with `hang: true`; steps open the dialog and
click "Cancel invite"; "Cancelling…" loading, "Keep invite" disabled.
- [ ] Cancel invite: failed: mock `DELETE /v1/user/invite/2` status 500; steps open the dialog and confirm; the
notice "Couldn't cancel the invite. Try again in a moment." inside the dialog, buttons enabled.
- [ ] Cancel invite: done: `{ path: '/users', skip: 'clock-sensitive' }` (per the user's request; the design does
not mark it clock-sensitive). Prints SKIP, never investigated or date-frozen. Vitest case 8 covers it.
- [ ] Renew invite: sending: mock `POST /v1/user/invite/1/renew` status 200 with `hang: true`; steps click "Renew
invite" on the p.huber row; only that button shows "Renewing…", loader-circle, 50 % opacity.
- [ ] Renew invite: done: `{ path: '/users', skip: 'clock-sensitive' }` ("Today"). Vitest case 12 covers it.
- [ ] Renew invite: failed: mock `POST /v1/user/invite/1/renew` status 500; steps click "Renew invite"; the error
toast "Couldn't renew the invite" / "Try again in a moment." is visible and the p.huber row is Expired with
"Renew invite". Keep the toast on screen for the capture; if the runner can't, stop and ask.
- [ ] Default: with the exception removed it passes at 10 % with the drawn buttons; if it fails, stop and ask (no
exception without approval).
- [ ] Not drawn, no visual state: non-owner rendering and the 404 fallback. Vitest only (cases 2, 6, 13).
- [ ] Not covered by screenshots: keyboard order (Tab reaches each row action; in the dialog focus starts on "Keep
invite", is trapped, Esc closes it except while Cancelling…, focus return after the dialog closes); hover and
active on the buttons; motion (modal 240 ms, toast enter and exit), stopped under reduced motion.
## Manual verification
- Real round trips against `task dev`, as owner: cancel a pending invite (row disappears, the old link at
/invite/accept?c=... answers "This invite link doesn't work."), renew an expired one (row moves to top with
"Today", old link dead, new link works), and reload to confirm the order matches the API.
- Real invitation email on renew needs real or sandboxed SMTP (`SMTP_HOST` and related vars): confirm the new mail
arrives with the new link and the old one no longer works.
- Failure paths with the API stopped or SMTP failing: the renew toast and the cancel dialog notice appear, and a
retry succeeds.
- Admin session against `task dev`: no row actions are shown.
- Toast placement and stacking on desktop (bottom right) and above the pinned "Invite user" button on mobile.
- Focus after a successful cancel (the row is gone) lands somewhere sensible.
- "Today" and the elevated row on a live app after Renew, since visual:check skips it.
+2 -3
View File
@@ -35,7 +35,7 @@ Feature: Users screen and InviteUserModal
the "Only you" hint is hidden; this is not drawn in docs/design, so it has no visual state 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
+1 -2
View File
@@ -96,8 +96,7 @@ capture of every non-skipped state must be within 10 %, for both viewports and b
are updated so "Display names in the Users list" shows "Marta Alves", owner. 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.
+11 -9
View File
@@ -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 }}
+17
View File
@@ -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>
+66
View File
@@ -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>
+17 -1
View File
@@ -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>
+9 -1
View File
@@ -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
View File
@@ -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()
})
})
+19 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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: {