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

+11 -9
View File
@@ -85,22 +85,24 @@ function onCancel() {
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template v-if="error" #body>
<template #body>
<UAlert
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
</template>
<template #footer>
@@ -110,7 +112,7 @@ function onCancel() {
color="neutral"
variant="outline"
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"
@click="onCancel"
>
@@ -121,7 +123,7 @@ function onCancel() {
:loading="pending"
:disabled="pending"
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"
>
{{ confirmText }}
+17
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import {
@@ -16,6 +17,13 @@ defineProps<{
loading: boolean
sessionUserId?: number
highlightedKey?: string
canManage?: boolean
busyKeys?: string[]
}>()
const emit = defineEmits<{
cancel: [item: UserListItem]
renew: [item: UserListItem]
}>()
function rowKey(item: UserListItem) {
@@ -56,6 +64,15 @@ function rowKey(item: UserListItem) {
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
</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>
</template>
</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">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserRowAction from '@/components/UserRowAction.vue'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import {
@@ -19,6 +20,13 @@ defineProps<{
loading: boolean
sessionUserId?: number
highlightedKey?: string
canManage?: boolean
busyKeys?: string[]
}>()
const emit = defineEmits<{
cancel: [item: UserListItem]
renew: [item: UserListItem]
}>()
function rowKey(item: UserListItem) {
@@ -74,7 +82,15 @@ function rowKey(item: UserListItem) {
<span><RoleBadge :role="userRole(item)" /></span>
<span><InviteStatusBadge :status="item.status" /></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>
</template>
</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">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
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 InviteUserModal from '@/components/InviteUserModal.vue'
import UserCardList from '@/components/UserCardList.vue'
import UsersTable from '@/components/UsersTable.vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
@@ -14,9 +16,13 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
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 overlay = useOverlay()
const session = useSessionStore()
const { listUsers } = useUsers()
const { listUsers, cancelInvite, renewInvite } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
@@ -24,6 +30,7 @@ const items = ref<UserListItem[]>([])
const showSkeleton = ref(false)
const inviteModalOpen = ref(false)
const highlightedKey = ref('')
const busyKeys = ref<string[]>([])
let skeletonTimer: ReturnType<typeof setTimeout> | 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)
onBeforeUnmount(clearSkeletonTimer)
</script>
@@ -162,6 +253,10 @@ onBeforeUnmount(clearSkeletonTimer)
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
:can-manage="canInvite"
:busy-keys="busyKeys"
@cancel="confirmCancel"
@renew="renew"
/>
<UserCardList
v-else
@@ -169,6 +264,10 @@ onBeforeUnmount(clearSkeletonTimer)
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
:can-manage="canInvite"
:busy-keys="busyKeys"
@cancel="confirmCancel"
@renew="renew"
/>
</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(rowTexts()).toEqual([
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'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',
'jonas@renew.example — admin Accepted 2 Apr 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)
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 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 Renew invite',
'ines.duarte@renew.example Inès Duarte admin Accepted Created 18 Jun 2026',
'jonas@renew.example admin Accepted Created 2 Apr 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()
})
it.each([true, false])('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')
it.each([true, false])(
'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(document.body.textContent).not.toContain('Invite someone so they can sign in')
expect(findButton('Invite user')).toBeUndefined()
})
expect(rows()).toHaveLength(2)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
expect(findButton('Invite user')).toBeUndefined()
},
)
})
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[0]).toContain('lea.martin@renew.example — admin Pending Today')
expect(texts.slice(1)).toEqual([
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026 Cancel invite',
'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',
'jonas@renew.example — admin Accepted 2 Apr 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) {
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 }
}
+61 -4
View File
@@ -634,6 +634,33 @@ async function openInviteModal(page: Page) {
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) {
return async (page: Page) => {
await openInviteModal(page)
@@ -845,10 +872,40 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
Default: {
path: '/users',
api: usersApi(),
exception: {
maxPercent: 10.5,
reason: 'Row actions (Cancel invite, Renew invite) drawn on the board are out of scope for this slice',
approvedBy: 'user',
},
'Cancel invite: confirm': {
path: '/users',
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: {