feat(SPA): users page and invite modal

This commit is contained in:
fsylvain committed 2026-09-29 08:35:14 +02:00
1 parent acdef18b37
commit 6afffc1984
20 files changed
+1880 -55

No files matched your search

+1
View File
@@ -36,6 +36,7 @@ const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
].map((item) => ({
...item,
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
status: 'accepted' | 'pending' | 'expired'
size?: 'sm' | 'md'
}>(),
{
size: 'md',
},
)
const badges = {
accepted: {
label: 'Accepted',
icon: 'i-lucide-circle-check',
color: 'success',
class: 'border border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
},
pending: {
label: 'Pending',
icon: 'i-lucide-mail',
color: 'warning',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
expired: {
label: 'Expired',
icon: 'i-lucide-clock-alert',
color: 'neutral',
class: 'border border-accented bg-transparent text-muted',
},
} as const
const badge = computed(() => badges[props.status])
const sizeClass = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : 'h-5.5 px-1.75'))
</script>
<template>
<UBadge
:color="badge.color"
variant="soft"
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:ui="{ base: 'gap-1 py-0 text-xs', leadingIcon: 'size-3' }"
>
{{ badge.label }}
</UBadge>
</template>
+233
View File
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, type CreatedInvite } from '@/composables/useUsers'
const EMAIL_PLACEHOLDER = 'name@example.com'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
const INVALID_EMAIL_MESSAGE = 'Enter a full email address, like name@example.com.'
const HAS_ACCOUNT_MESSAGE = 'This address already has an account.'
const PENDING_INVITE_MESSAGE = "There's already a pending invite for this address."
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ invited: [invite: CreatedInvite] }>()
const { inviteUser } = useUsers()
const email = ref('')
const touched = ref(false)
const focused = ref(false)
const submitting = ref(false)
const serverError = ref('')
const formAlert = ref<'' | 'invalid' | 'rateLimited' | 'failed'>('')
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const emailError = computed(
() => serverError.value || (touched.value && !emailValid.value ? INVALID_EMAIL_MESSAGE : ''),
)
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'rateLimited') {
return 'Too many invites. Wait a moment, then try again.'
}
if (formAlert.value === 'failed') {
return "Couldn't send the invite. Try again in a moment."
}
return ''
})
function resetForm() {
email.value = ''
touched.value = false
focused.value = false
submitting.value = false
serverError.value = ''
formAlert.value = ''
}
function emailInput() {
return document.querySelector<HTMLInputElement>(`input[placeholder="${EMAIL_PLACEHOLDER}"]`)
}
async function focusEmailField() {
await nextTick()
await nextTick()
emailInput()?.focus()
focused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusEmailField()
},
{ immediate: true },
)
watch(email, () => {
serverError.value = ''
})
function onBlur() {
touched.value = true
focused.value = false
}
function messageForConflict(error: ApiError) {
return error.body === PENDING_INVITE_MESSAGE ? PENDING_INVITE_MESSAGE : HAS_ACCOUNT_MESSAGE
}
function showFailure(error: unknown) {
if (error instanceof ApiError && error.status === 409) {
serverError.value = messageForConflict(error)
return
}
if (error instanceof ApiError && error.status === 400) {
serverError.value = INVALID_EMAIL_MESSAGE
formAlert.value = 'invalid'
return
}
formAlert.value = error instanceof ApiError && error.status === 429 ? 'rateLimited' : 'failed'
}
async function onSubmit() {
if (submitting.value) {
return
}
touched.value = true
focused.value = false
if (!emailValid.value) {
emailInput()?.focus()
return
}
serverError.value = ''
formAlert.value = ''
submitting.value = true
try {
const invite = await inviteUser(email.value)
open.value = false
emit('invited', invite)
} catch (error) {
showFailure(error)
} finally {
submitting.value = false
}
}
function onOpenChange(isOpen: boolean) {
if (!isOpen && submitting.value) {
return
}
open.value = isOpen
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal
:open="open"
title="Invite a user"
description="We'll email them a link to choose a username and password. The link expires after 3 days."
:dismissible="!submitting"
:close="{ color: 'neutral', variant: 'ghost', class: 'text-muted' }"
@update:open="onOpenChange"
:ui="{
content:
'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: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
close: 'top-5 end-6 size-7 justify-center p-0',
title: 'text-lg/6 font-semibold',
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 #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ email }" class="flex flex-col gap-4" @submit="onSubmit">
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:ui="{ error: 'mt-1.5 text-xs/5' }"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
v-model="email"
type="email"
:placeholder="EMAIL_PLACEHOLDER"
autocomplete="off"
:disabled="submitting"
color="primary"
:highlight="focused || !!emailError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="focused = true"
@blur="onBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Sending…' : 'Send invite' }}
</UButton>
</div>
</template>
</UModal>
</template>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<li
v-for="n in USER_SKELETON_ROWS"
:key="n"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
>
<USkeleton class="h-3.5 w-[55%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[40%] rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li
v-for="item in items"
:key="rowKey(item)"
data-testid="user-card"
class="flex min-h-17 flex-col justify-center gap-1.5 border-b border-default px-4 py-3"
:class="{ 'bg-elevated': rowKey(item) === highlightedKey }"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
<div class="flex items-center gap-2.5 text-xs text-muted">
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
</div>
</li>
</template>
</ul>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { UserListItem } from '@/composables/useUsers'
const props = defineProps<{
item: UserListItem
isYou: boolean
large?: boolean
}>()
const primary = computed(() => {
if (props.item.kind === 'invite') {
return props.item.email
}
return props.item.display_name || props.item.username
})
const secondary = computed(() =>
props.item.kind === 'user' && props.item.display_name ? props.item.username : '',
)
const primaryClass = computed(() => [
props.item.kind === 'invite' ? 'text-toned' : 'font-medium text-highlighted',
props.large ? 'text-[0.9375rem]/[21px]' : '',
])
</script>
<template>
<span class="flex min-w-0 items-center gap-2">
<span class="truncate" :class="primaryClass">{{ primary }}</span>
<span v-if="secondary" class="shrink-0 text-muted">{{ secondary }}</span>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
</span>
</template>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
defineProps<{
items: UserListItem[]
loading: boolean
sessionUserId?: number
highlightedKey?: string
}>()
function rowKey(item: UserListItem) {
return userRowKey(item.kind, item.id)
}
</script>
<template>
<div class="border border-default bg-default" :aria-busy="loading ? 'true' : undefined">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span>Username</span>
<span>Invite status</span>
<span class="flex items-center gap-1 text-highlighted">
Created
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
<div
v-for="n in USER_SKELETON_ROWS"
:key="n"
:class="ROW_GRID"
class="h-11 border-t border-default"
>
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
<USkeleton class="h-5 w-19 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<div
v-for="item in items"
:key="rowKey(item)"
data-testid="user-row"
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
class="h-11 border-t border-default"
>
<UserNameCell
:item="item"
:is-you="item.kind === 'user' && item.id === sessionUserId"
class="pr-3"
/>
<span><InviteStatusBadge :status="item.status" /></span>
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
</div>
</template>
</div>
</template>
@@ -36,29 +36,39 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the three navigation destinations', () => {
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the four navigation destinations', () => {
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
const users = wrapper.find('a[href="/users"]')
const integration = wrapper.find('a[href="/integration"]')
expect(home.exists()).toBe(true)
expect(articles.exists()).toBe(true)
expect(integration.exists()).toBe(true)
expect(articles.attributes('aria-current')).toBe('page')
expect(home.attributes('aria-current')).toBeUndefined()
expect(users.attributes('aria-current')).toBeUndefined()
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('marks Users with aria-current on /users', async () => {
const wrapper = await mountShell('/users')
const users = wrapper.find('a[href="/users"]')
expect(users.text()).toContain('Users')
expect(users.attributes('aria-current')).toBe('page')
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
})
})
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const wrapper = await mountShell('/article')
@@ -79,16 +89,12 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
const wrapper = await mountShell('/home')
const header = wrapper.find('[data-slot="header"]')
expect(
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
).toBe(true)
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
expect(header.text()).toContain('RenewCMS')
expect(header.find('a').exists()).toBe(false)
const headerHtml = header.html()
expect(headerHtml.indexOf('<svg')).toBeLessThan(
headerHtml.indexOf('RenewCMS</span>'),
)
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
})
})
@@ -97,9 +103,7 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
await mountShell('/home')
const collapseButton = () =>
document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
@@ -120,13 +124,11 @@ describe('A successful logout clears the session and navigates to /login', () =>
await mountShell('/home')
const logOutItem = () =>
Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
) as HTMLElement
const trigger = document.body.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
trigger.click()
await flushPromises()
@@ -151,28 +153,16 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
collapseButton.click()
await flushPromises()
const header = document.body.querySelector(
'[data-slot="header"]',
) as HTMLElement
expect(
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
).toBeTruthy()
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
expect(header.textContent).not.toContain('RenewCMS')
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
const footer = document.body.querySelector(
'[data-slot="footer"]',
) as HTMLElement
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).not.toContain('marta')
const userMenuTrigger = footer.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect(
footer.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
+21 -6
View File
@@ -5,11 +5,13 @@ import { useSessionStore } from '@/stores/session'
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
constructor(status: number, retryAfter?: number) {
constructor(status: number, retryAfter?: number, body?: string) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
}
}
@@ -20,6 +22,7 @@ export class NetworkError extends Error {
}
export interface MeResult {
id?: number
verified: boolean
username?: string
}
@@ -51,6 +54,17 @@ function parseRetryAfterSeconds(response: Response): number | undefined {
return Number.parseInt(header, 10)
}
async function readErrorBody(response: Response): Promise<string | undefined> {
if (typeof response.text !== 'function') {
return undefined
}
try {
return (await response.text()).trim()
} catch {
return undefined
}
}
if (typeof window !== 'undefined') {
window.addEventListener('offline', () => {
isOffline.value = true
@@ -102,7 +116,7 @@ export function useApi() {
throw new ApiError(429, parseRetryAfterSeconds(response))
}
throw new ApiError(response.status)
throw new ApiError(response.status, undefined, await readErrorBody(response))
}
if (response.status === 204) {
@@ -113,10 +127,11 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified), username: body.username }
const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
'/v1/auth/me',
{ onUnauthorized: 'passthrough' },
)
return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
}
return { getMe, request }
+51
View File
@@ -0,0 +1,51 @@
import { useApi } from '@/composables/useApi'
export type UserListItem =
| {
kind: 'user'
id: number
username: string
display_name: string
status: 'accepted'
created_at: string
}
| {
kind: 'invite'
id: number
email: string
status: 'pending' | 'expired'
created_at: string
}
export const USER_SKELETON_ROWS = 5
export function userRowKey(kind: UserListItem['kind'], id: number) {
return `${kind}-${id}`
}
export interface CreatedInvite {
id: number
kind: 'invite'
email: string
status: 'pending'
created_at: string
expires_at: string
}
export function useUsers() {
const { request } = useApi()
function listUsers(): Promise<UserListItem[]> {
return request<UserListItem[]>('/v1/user/')
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
}
return { listUsers, inviteUser }
}
+13
View File
@@ -47,3 +47,16 @@ export function formatRelativeDate(iso: string, now: Date = new Date()) {
}
return formatAbsoluteDate(date)
}
function isSameLocalDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
export function formatCreatedDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
return isSameLocalDay(date, now) ? 'Today' : formatAbsoluteDate(date)
}
+5
View File
@@ -66,6 +66,11 @@ const router = createRouter({
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
+1
View File
@@ -5,6 +5,7 @@ import { ApiError, useApi } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
export interface Session {
id?: number
verified: boolean
username?: string
}
+193
View File
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
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 { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
const SKELETON_DELAY_MS = 150
const DESKTOP_QUERY = '(min-width: 48rem)'
type LoadState = 'loading' | 'ready' | 'error'
const toast = useToast()
const session = useSessionStore()
const { listUsers } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const loadState = ref<LoadState>('loading')
const items = ref<UserListItem[]>([])
const showSkeleton = ref(false)
const inviteModalOpen = ref(false)
const highlightedKey = ref('')
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
const sessionUserId = computed(() => session.session?.id)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
const showOnlyYouHint = computed(() => loadState.value === 'ready' && items.value.length === 1)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadUsers() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
items.value = await listUsers()
highlightedKey.value = ''
loadState.value = 'ready'
} catch {
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
function openInviteModal() {
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
inviteModalOpen.value = true
}
async function restoreFocus() {
await nextTick()
opener?.focus()
opener = null
}
function onInviteModalOpenChange(isOpen: boolean) {
inviteModalOpen.value = isOpen
if (!isOpen) {
restoreFocus()
}
}
function onInvited(invite: CreatedInvite) {
const sameAddress = invite.email.toLowerCase()
const newRow: UserListItem = {
kind: 'invite',
id: invite.id,
email: invite.email,
status: 'pending',
created_at: invite.created_at,
}
items.value = [
newRow,
...items.value.filter(
(item) => !(item.kind === 'invite' && item.email.toLowerCase() === sameAddress),
),
]
highlightedKey.value = userRowKey('invite', invite.id)
toast.add({
id: `invite-sent-${invite.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite sent',
description: `${invite.email} has 3 days to accept.`,
})
}
onMounted(loadUsers)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Users</h1>
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
</div>
<UButton
v-if="isDesktop"
icon="i-lucide-user-plus"
class="h-8 px-3"
@click="openInviteModal"
>
Invite user
</UButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load users"
description="Check your connection and try again."
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadUsers"
>
Try again
</UButton>
</template>
</UAlert>
<template v-if="showList">
<UsersTable
v-if="isDesktop"
:items="items"
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
/>
<UserCardList
v-else
:items="items"
:loading="loadState === 'loading'"
:session-user-id="sessionUserId"
:highlighted-key="highlightedKey"
/>
</template>
<p v-if="showOnlyYouHint" class="text-pretty text-muted">
Invite someone so they can sign in with their own account instead of sharing yours.
</p>
</div>
<UButton
v-if="!isDesktop"
v-show="!inviteModalOpen"
icon="i-lucide-user-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
@click="openInviteModal"
>
Invite user
</UButton>
<InviteUserModal
:open="inviteModalOpen"
@update:open="onInviteModalOpenChange"
@invited="onInvited"
/>
</DashboardShell>
</template>
+641
View File
@@ -0,0 +1,641 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { CreatedInvite, 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 INVITE_PATH = '/v1/user/invite'
const INVALID_EMAIL = 'Enter a full email address, like name@example.com.'
const FAILED_ALERT = "Couldn't send the invite. Try again in a moment."
const VALID_EMAIL = 'lea.martin@renew.example'
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',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
]
}
function onlyYouFixture(): UserListItem[] {
return defaultFixture().slice(4)
}
function inviteFixture(email = VALID_EMAIL): CreatedInvite {
return {
id: 3,
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 {
list?: () => Promise<unknown>
invite?: () => Promise<unknown>
}
async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === LIST_PATH) {
return (list ?? (() => Promise.resolve(defaultFixture())))()
}
if (path === INVITE_PATH) {
return (invite ?? (() => Promise.resolve(inviteFixture())))()
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: requestMock,
})
await router.push('/users')
wrapper = mount(UsersView, {
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 textOf(element: Element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
function rowTexts() {
return rows().map(textOf)
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await flushPromises()
}
function emailInput() {
return document.body.querySelector<HTMLInputElement>('input[placeholder="name@example.com"]')!
}
function dialog() {
return document.body.querySelector('[role="dialog"]')
}
function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function typeEmail(value: string) {
const input = emailInput()
input.value = value
dispatch(input, 'input')
await flushPromises()
}
async function blurEmail() {
dispatch(emailInput(), 'blur')
await flushPromises()
}
async function openModal() {
const opener = findButton('Invite user')!
opener.focus()
opener.click()
await flushPromises()
}
async function submitWith(value: string) {
await typeEmail(value)
await clickButton('Send invite')
}
function inviteCalls() {
return requestMock.mock.calls.filter(([path]) => path === INVITE_PATH)
}
function listCalls() {
return requestMock.mock.calls.filter(([path]) => path === LIST_PATH)
}
function alertText() {
return document.body.querySelector('[role="alert"]')?.textContent?.trim() ?? ''
}
beforeEach(() => {
toastAdd = vi.fn()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Default — the list loads newest first, with display name, "you" tag and status badges', () => {
it('shows the header, the rows in fixture order and the badges on desktop', async () => {
await mountPage()
const breadcrumb = document.body.querySelector(
'[aria-label="breadcrumb"], nav:not([aria-label="Main"])',
)
expect(breadcrumb?.textContent).toContain('Users')
expect(document.body.querySelector('h1')?.textContent).toBe('Users')
expect(document.body.textContent).toContain(
'Everyone who can sign in and work on your articles.',
)
expect(findButton('Invite user')).toBeTruthy()
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example Pending 27 Sep 2026',
'p.huber@gmail.com Expired 3 Aug 2026',
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'marta you Accepted 12 Mar 2026',
])
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
expect(document.body.textContent).toContain('Invite status')
})
it('renders the same rows as cards below md', async () => {
await mountPage({}, false)
expect(rowTexts()).toEqual([
'sam.oliveira@renew.example Pending Created 27 Sep 2026',
'p.huber@gmail.com Expired Created 3 Aug 2026',
'Inès Duarte ines Accepted Created 18 Jun 2026',
'jonas Accepted Created 2 Apr 2026',
'marta you Accepted Created 12 Mar 2026',
])
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
})
it('gives the Users link aria-current on /users', async () => {
await mountPage()
const link = document.body.querySelector('nav[aria-label="Main"] a[href="/users"]')
expect(link?.getAttribute('aria-current')).toBe('page')
})
})
describe('Loading — skeleton rows after 150 ms', () => {
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<UserListItem[]>()
await mountPage({ list: () => pending.promise })
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(document.body.querySelector('[data-slot="base"].animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(5)
expect(rows()).toHaveLength(0)
pending.resolve(defaultFixture())
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(5)
})
})
describe('Only you — nobody has been invited yet', () => {
it('shows the one row, the hint and Invite user', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
expect(rowTexts()).toEqual(['marta you Accepted 12 Mar 2026'])
expect(document.body.textContent).toContain(
'Invite someone so they can sign in with their own account instead of sharing yours.',
)
expect(findButton('Invite user')).toBeTruthy()
})
})
describe('Error — a 500 or a network failure replaces the list with an alert and a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
await mountPage({ list: failure })
expect(alertText()).toContain("Couldn't load users")
expect(alertText()).toContain('Check your connection and try again.')
expect(findButton('Try again')).toBeTruthy()
expect(document.body.querySelector('[role="alert"] button[aria-label="Close"]')).toBeNull()
expect(rows()).toHaveLength(0)
expect(findButton('Invite user')).toBeTruthy()
requestMock.mockImplementation((path: string) =>
path === LIST_PATH
? Promise.resolve(defaultFixture())
: Promise.reject(new Error(path)),
)
await clickButton('Try again')
expect(listCalls()).toHaveLength(2)
expect(rows()).toHaveLength(5)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Invite: default — opening the modal', () => {
it('shows the copy, an empty focused Email and both buttons, and sends nothing', async () => {
await mountPage()
await openModal()
expect(dialog()?.textContent).toContain('Invite a user')
expect(dialog()?.textContent).toContain(
"We'll email them a link to choose a username and password. The link expires after 3 days.",
)
expect(emailInput().value).toBe('')
expect(document.activeElement).toBe(emailInput())
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Send invite')).toBeTruthy()
expect(inviteCalls()).toHaveLength(0)
expect(rows()).toHaveLength(5)
})
it('hides the pinned button below md while the modal is open', async () => {
await mountPage({}, false)
const pinned = findButton('Invite user')!
expect(pinned.style.display).not.toBe('none')
await openModal()
expect(pinned.style.display).toBe('none')
await clickButton('Cancel')
expect(pinned.style.display).not.toBe('none')
})
})
describe('Invite: validation — an invalid address is refused before any request', () => {
it.each([
{ value: 'sam.oliveira@renew', trigger: 'blur' },
{ value: 'not-an-email', trigger: 'submit' },
{ value: '', trigger: 'submit' },
])('refuses "$value" on $trigger', async ({ value, trigger }) => {
await mountPage()
await openModal()
await typeEmail(value)
if (trigger === 'blur') {
await blurEmail()
} else {
await clickButton('Send invite')
expect(document.activeElement).toBe(emailInput())
}
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
expect(inviteCalls()).toHaveLength(0)
})
it('clears the error as soon as the address is fixed', async () => {
await mountPage()
await openModal()
await typeEmail('sam.oliveira@renew')
await blurEmail()
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
await typeEmail('sam.oliveira@renew.example')
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
})
})
describe('Invite: sending — the form is locked and cannot be dismissed', () => {
it('sends the JSON body, disables the form and ignores Esc and the backdrop', async () => {
const pending = deferred<CreatedInvite>()
await mountPage({ invite: () => pending.promise })
await openModal()
await submitWith(VALID_EMAIL)
expect(inviteCalls()).toHaveLength(1)
const [, options] = inviteCalls()[0]
expect(options.method).toBe('POST')
expect(JSON.parse(options.body)).toEqual({ email: VALID_EMAIL })
expect(emailInput().disabled).toBe(true)
expect(findButton('Cancel')!.disabled).toBe(true)
expect(findButton('Sending…')).toBeTruthy()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).toBeTruthy()
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
await flushPromises()
expect(dialog()).toBeTruthy()
pending.resolve(inviteFixture())
await flushPromises()
})
})
describe('Invite sent — a 201 closes the modal, adds a Pending row on top and raises a toast', () => {
it('closes, prepends the highlighted row without a refetch and toasts', async () => {
await mountPage()
await openModal()
await submitWith(VALID_EMAIL)
expect(dialog()).toBeNull()
expect(listCalls()).toHaveLength(1)
const texts = rowTexts()
expect(texts).toHaveLength(6)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts.slice(1)).toEqual([
'sam.oliveira@renew.example Pending 27 Sep 2026',
'p.huber@gmail.com Expired 3 Aug 2026',
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'marta you Accepted 12 Mar 2026',
])
expect(rows()[0].className).toContain('bg-elevated')
expect(rows()[1].className).not.toContain('bg-elevated')
expect(toastAdd).toHaveBeenCalledWith({
id: 'invite-sent-3',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Invite sent',
description: 'lea.martin@renew.example has 3 days to accept.',
})
})
it('returns focus to Invite user', async () => {
await mountPage()
await openModal()
await submitWith(VALID_EMAIL)
expect(document.activeElement).toBe(findButton('Invite user'))
})
})
describe('Invite sent to an address whose invite had expired replaces the old row', () => {
it('keeps exactly one Pending row for the address, first', async () => {
await mountPage({ invite: () => Promise.resolve(inviteFixture('p.huber@gmail.com')) })
await openModal()
await submitWith('p.huber@gmail.com')
const matching = rowTexts().filter((text) => text.includes('p.huber@gmail.com'))
expect(matching).toHaveLength(1)
expect(rowTexts()[0]).toContain('p.huber@gmail.com Pending')
expect(rows()).toHaveLength(5)
})
})
describe('Invite sent into the Only-you state keeps a coherent list', () => {
it('puts the new row above marta and hides the hint', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) })
await openModal()
await submitWith(VALID_EMAIL)
const texts = rowTexts()
expect(texts).toHaveLength(2)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts[1]).toContain('marta you Accepted')
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
})
})
describe('Invite: already a user (409) — the field error depends on the response text', () => {
it.each([
{
email: 'jonas@renew.example',
body: 'This address already has an account.',
error: 'This address already has an account.',
},
{
email: 'sam.oliveira@renew.example',
body: "There's already a pending invite for this address.",
error: "There's already a pending invite for this address.",
},
{
email: 'jonas@renew.example',
body: 'Something else',
error: 'This address already has an account.',
},
])('shows "$error" for the body "$body"', async ({ email, body, error }) => {
await mountPage({ invite: () => Promise.reject(new ApiError(409, undefined, body)) })
await openModal()
await submitWith(email)
expect(dialog()).toBeTruthy()
expect(emailInput().value).toBe(email)
expect(emailInput().disabled).toBe(false)
expect(dialog()?.textContent).toContain(error)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe("A 400 that got past this form's own check", () => {
it('keeps the address and shows the alert and the field error', async () => {
await mountPage({ invite: () => Promise.reject(new ApiError(400)) })
await openModal()
await submitWith(VALID_EMAIL)
expect(emailInput().value).toBe(VALID_EMAIL)
expect(emailInput().disabled).toBe(false)
expect(alertText()).toContain(
"Something in the form isn't valid. Check the highlighted fields.",
)
expect(dialog()?.textContent).toContain(INVALID_EMAIL)
})
})
describe('Invite: rate limited — a 429 shows a dialog alert and keeps the address', () => {
it('shows the fixed alert, no field error, then succeeds on retry', async () => {
const outcomes = [
() =>
Promise.reject(
new ApiError(429, undefined, 'Too many requests. Please try again later.'),
),
() => Promise.resolve(inviteFixture()),
]
await mountPage({ invite: () => outcomes.shift()!() })
await openModal()
await submitWith(VALID_EMAIL)
expect(alertText()).toBe('Too many invites. Wait a moment, then try again.')
expect(dialog()?.textContent).not.toContain('Too many requests')
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
expect(emailInput().value).toBe(VALID_EMAIL)
expect(emailInput().disabled).toBe(false)
expect(rows()).toHaveLength(5)
await clickButton('Send invite')
expect(dialog()).toBeNull()
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})
describe('Invite: failed — a 500 or a network failure keeps the address and offers a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{
name: 'a 500 with the API text',
failure: () => Promise.reject(new ApiError(500, undefined, FAILED_ALERT)),
},
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('shows the alert once on $name', async ({ failure }) => {
await mountPage({ invite: failure })
await openModal()
await submitWith(VALID_EMAIL)
expect(alertText()).toBe(FAILED_ALERT)
expect(document.body.querySelectorAll('[role="alert"]')).toHaveLength(1)
expect(emailInput().value).toBe(VALID_EMAIL)
expect(emailInput().disabled).toBe(false)
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
expect(rows()).toHaveLength(5)
})
})
describe('Retrying after a failure succeeds', () => {
it('sends the same body again and completes', async () => {
const outcomes = [
() => Promise.reject(new ApiError(500)),
() => Promise.resolve(inviteFixture()),
]
await mountPage({ invite: () => outcomes.shift()!() })
await openModal()
await submitWith(VALID_EMAIL)
await clickButton('Send invite')
expect(inviteCalls()).toHaveLength(2)
expect(inviteCalls()[1][1].body).toBe(inviteCalls()[0][1].body)
expect(dialog()).toBeNull()
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})
describe('Cancel discards the address with no confirmation', () => {
it('closes at once, sends nothing, restores focus and reopens empty', async () => {
await mountPage()
await openModal()
await typeEmail(VALID_EMAIL)
await clickButton('Cancel')
expect(dialog()).toBeNull()
expect(inviteCalls()).toHaveLength(0)
expect(document.activeElement).toBe(findButton('Invite user'))
await openModal()
expect(emailInput().value).toBe('')
expect(dialog()?.textContent).not.toContain(INVALID_EMAIL)
})
})