feat: UsersTable and UserCardList get Email, Display name (dash when empty), Role (new RoleBadge), Invite status and Created

This commit is contained in:
fsylvain committed 2026-09-30 02:00:46 +02:00
1 parent 153fd8719a
commit 1ece778caf
19 files changed
+485 -204

No files matched your search

+6 -21
View File
@@ -48,9 +48,7 @@ function formatDetailDate(iso: string, relative = false) {
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() =>
formatDetailDate(props.article.updated_at, true),
)
const updatedLabel = computed(() => formatDetailDate(props.article.updated_at, true))
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => props.article.images.length)
const apiNote = computed(() =>
@@ -94,9 +92,7 @@ async function copyUrl() {
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">
Details
</h2>
<h2 class="text-base font-semibold text-highlighted">Details</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
@@ -110,9 +106,7 @@ async function copyUrl() {
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<div class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs">
<span class="flex-1">{{ publicUrl }}</span>
<UButton
icon="i-lucide-copy"
@@ -126,20 +120,13 @@ async function copyUrl() {
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
>
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
</div>
</UCard>
<USlideover
v-model:open="open"
side="right"
title="Details"
class="lg:hidden"
>
<USlideover v-model:open="open" side="right" title="Details" class="lg:hidden">
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
@@ -172,9 +159,7 @@ async function copyUrl() {
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3"
>
<div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
+4 -12
View File
@@ -33,26 +33,18 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() =>
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
)
const inputClass = computed(() =>
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
)
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() =>
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
)
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardUi = computed(() => ({
root: belowSm.value
? 'rounded-none ring-0'
: 'rounded-none ring-0 border border-default',
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
+5 -15
View File
@@ -25,12 +25,8 @@ const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() =>
props.tone === 'danger' ? 'error' : 'primary',
)
const confirmText = computed(() =>
pending.value ? props.pendingLabel : props.confirmLabel,
)
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
onMounted(async () => {
await nextTick()
@@ -62,10 +58,7 @@ async function onConfirm() {
await props.action()
settle(true)
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Something went wrong. Try again.'
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
} finally {
pending.value = false
}
@@ -95,8 +88,7 @@ function onCancel() {
'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]',
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',
}"
@@ -112,9 +104,7 @@ function onCancel() {
/>
</template>
<template #footer>
<div
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
+2 -9
View File
@@ -44,15 +44,8 @@ const items = computed(() =>
<template>
<ul class="flex flex-col gap-1.5">
<li
v-for="item in items"
:key="item.label"
class="flex items-center gap-2 text-xs/[18px]"
>
<UIcon
:name="item.icon"
:class="['size-3.5 shrink-0', item.iconClass]"
/>
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
role: 'owner' | 'admin'
}>()
const roleClass = computed(() =>
props.role === 'owner'
? 'border border-primary bg-primary text-(--ui-bg)'
: 'border border-accented bg-transparent text-toned',
)
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:class="roleClass"
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium' }"
>
{{ role }}
</UBadge>
</template>
+1 -4
View File
@@ -76,10 +76,7 @@ const savedText = computed(() => {
<span>Unsaved changes</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon
name="i-lucide-loader-circle"
class="size-3.5 animate-spin"
/>
<UIcon name="i-lucide-loader-circle" class="size-3.5 animate-spin" />
<span>Saving…</span>
</template>
<template v-else-if="state === 'error'">
+16 -5
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
import {
USER_SKELETON_ROWS,
userDisplayName,
userRole,
userRowKey,
type UserListItem,
} from '@/composables/useUsers'
defineProps<{
items: UserListItem[]
@@ -36,12 +43,16 @@ function rowKey(item: UserListItem) {
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"
<UserEmailCell
:email="item.email"
:is-you="item.kind === 'user' && item.id === sessionUserId"
large
/>
<div class="flex items-center gap-2.5 text-xs text-muted">
<span v-if="userDisplayName(item)" class="text-toned">{{
userDisplayName(item)
}}</span>
<div class="flex items-center gap-2 text-xs text-muted">
<RoleBadge :role="userRole(item)" />
<InviteStatusBadge :status="item.status" size="sm" />
<span>Created {{ formatCreatedDate(item.created_at) }}</span>
</div>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
email: string
isYou: boolean
large?: boolean
}>()
</script>
<template>
<span class="flex min-w-0 items-center gap-2">
<span
class="truncate font-medium text-highlighted"
:class="large ? 'text-[0.9375rem]/[21px]' : ''"
>{{ email }}</span
>
<span v-if="isYou" class="shrink-0 font-mono text-[0.6875rem] font-medium text-muted"
>you</span
>
</span>
</template>
-36
View File
@@ -1,36 +0,0 @@
<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>
+29 -10
View File
@@ -1,10 +1,18 @@
<script setup lang="ts">
import InviteStatusBadge from '@/components/InviteStatusBadge.vue'
import UserNameCell from '@/components/UserNameCell.vue'
import RoleBadge from '@/components/RoleBadge.vue'
import UserEmailCell from '@/components/UserEmailCell.vue'
import { formatCreatedDate } from '@/lib/relativeDate'
import { USER_SKELETON_ROWS, userRowKey, type UserListItem } from '@/composables/useUsers'
import {
USER_SKELETON_ROWS,
userDisplayName,
userRole,
userRowKey,
type UserListItem,
} from '@/composables/useUsers'
const ROW_GRID = 'grid grid-cols-[minmax(0,1fr)_160px_160px] items-center px-3'
const ROW_GRID =
'grid grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_84px_120px_110px_150px] items-center gap-3 px-3'
defineProps<{
items: UserListItem[]
@@ -21,12 +29,15 @@ function rowKey(item: UserListItem) {
<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>Email</span>
<span>Display name</span>
<span>Role</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>
<span></span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">Loading users</span>
@@ -34,9 +45,11 @@ function rowKey(item: UserListItem) {
v-for="n in USER_SKELETON_ROWS"
:key="n"
:class="ROW_GRID"
class="h-11 border-t border-default"
class="h-12 border-t border-default"
>
<USkeleton class="h-3 w-[30%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[60%] rounded-none bg-accented" />
<USkeleton class="h-3 w-[50%] rounded-none bg-accented" />
<USkeleton class="h-5 w-13 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>
@@ -47,15 +60,21 @@ function rowKey(item: UserListItem) {
:key="rowKey(item)"
data-testid="user-row"
:class="[ROW_GRID, { 'bg-elevated': rowKey(item) === highlightedKey }]"
class="h-11 border-t border-default"
class="h-12 border-t border-default"
>
<UserNameCell
:item="item"
<UserEmailCell
:email="item.email"
:is-you="item.kind === 'user' && item.id === sessionUserId"
class="pr-3"
/>
<span
class="truncate"
:class="userDisplayName(item) ? 'text-highlighted' : 'text-muted'"
>{{ userDisplayName(item) || '—' }}</span
>
<span><RoleBadge :role="userRole(item)" /></span>
<span><InviteStatusBadge :status="item.status" /></span>
<span class="text-muted">{{ formatCreatedDate(item.created_at) }}</span>
<span></span>
</div>
</template>
</div>
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { userRole, type UserListItem } from '@/composables/useUsers'
describe('userRole', () => {
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (role) => {
const item: UserListItem = {
kind: 'user',
id: 1,
username: 'u',
display_name: '',
email: 'u@renew.example',
role,
status: 'accepted',
created_at: '2026-03-12T00:00:00Z',
}
expect(userRole(item)).toBe(role)
})
it.each([['pending' as const], ['expired' as const]])(
'returns admin for a %s invite',
(status) => {
const item: UserListItem = {
kind: 'invite',
id: 2,
email: 'i@renew.example',
status,
created_at: '2026-03-12T00:00:00Z',
}
expect(userRole(item)).toBe('admin')
},
)
})
+5
View File
@@ -21,12 +21,15 @@ export class NetworkError extends Error {
}
}
export type UserRole = 'owner' | 'admin'
export interface MeResult {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
role?: UserRole
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -135,6 +138,7 @@ export function useApi() {
username?: string
email?: string
display_name?: string | null
role?: string
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
@@ -142,6 +146,7 @@ export function useApi() {
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
role: body.role as UserRole | undefined,
}
}
+11
View File
@@ -6,6 +6,8 @@ export type UserListItem =
id: number
username: string
display_name: string
email: string
role: 'owner' | 'admin'
status: 'accepted'
created_at: string
}
@@ -13,6 +15,7 @@ export type UserListItem =
kind: 'invite'
id: number
email: string
display_name?: string
status: 'pending' | 'expired'
created_at: string
}
@@ -23,6 +26,14 @@ export function userRowKey(kind: UserListItem['kind'], id: number) {
return `${kind}-${id}`
}
export function userDisplayName(item: UserListItem) {
return item.display_name ?? ''
}
export function userRole(item: UserListItem): 'owner' | 'admin' {
return item.kind === 'user' ? item.role : 'admin'
}
export interface CreatedInvite {
id: number
kind: 'invite'
+2 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import { ApiError, useApi, type UserRole } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
@@ -10,6 +10,7 @@ export interface Session {
username?: string
email?: string
displayName: string
role?: UserRole
}
export const useSessionStore = defineStore('session', () => {
+7 -3
View File
@@ -28,10 +28,13 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
const sessionUserId = computed(() => session.session?.id)
const canInvite = computed(() => session.session?.role === 'owner')
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
const showOnlyYouHint = computed(() => loadState.value === 'ready' && items.value.length === 1)
const showOnlyYouHint = computed(
() => loadState.value === 'ready' && items.value.length === 1 && canInvite.value,
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
@@ -84,6 +87,7 @@ function onInvited(invite: CreatedInvite) {
kind: 'invite',
id: invite.id,
email: invite.email,
display_name: '',
status: 'pending',
created_at: invite.created_at,
}
@@ -116,7 +120,7 @@ onBeforeUnmount(clearSkeletonTimer)
<p class="text-muted">Everyone who can sign in and work on your articles.</p>
</div>
<UButton
v-if="isDesktop"
v-if="isDesktop && canInvite"
icon="i-lucide-user-plus"
class="h-8 px-3"
@click="openInviteModal"
@@ -174,7 +178,7 @@ onBeforeUnmount(clearSkeletonTimer)
</div>
<UButton
v-if="!isDesktop"
v-if="!isDesktop && canInvite"
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)"
+146 -30
View File
@@ -45,6 +45,7 @@ function defaultFixture(): UserListItem[] {
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
@@ -52,6 +53,8 @@ function defaultFixture(): UserListItem[] {
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',
@@ -60,6 +63,8 @@ function defaultFixture(): UserListItem[] {
kind: 'user',
id: 2,
username: 'jonas',
email: 'jonas@renew.example',
role: 'admin',
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
@@ -68,6 +73,8 @@ function defaultFixture(): UserListItem[] {
kind: 'user',
id: 1,
username: 'marta',
email: 'marta@renew.example',
role: 'owner',
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
@@ -119,11 +126,21 @@ interface Handlers {
invite?: () => Promise<unknown>
}
async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
async function mountPage(
{ list, invite }: Handlers = {},
desktop = true,
role: string | undefined = 'owner',
) {
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: role ?? undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === LIST_PATH) {
@@ -135,7 +152,12 @@ async function mountPage({ list, invite }: Handlers = {}, desktop = true) {
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
role: role ?? undefined,
}),
request: requestMock,
})
await router.push('/users')
@@ -257,25 +279,70 @@ describe('Default — the list loads newest first, with display name, "you" tag
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',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'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',
])
expect(rowTexts().join(' ')).not.toMatch(/(ines|jonas|marta)(?!@)/)
expect(rows().filter((row) => /\byou\b/.test(textOf(row)))).toHaveLength(1)
expect(document.body.textContent).toContain('Invite status')
const header = document.body.querySelector(
'[data-testid="user-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Email Display name Role Invite status Created')
expect(header.children).toHaveLength(6)
expect(document.body.querySelectorAll('[data-testid="user-row"]')).toHaveLength(5)
})
it('styles the owner badge filled, the admin badge outlined, dashes muted and the you tag on marta only', async () => {
await mountPage()
const badge = (row: Element, role: string) =>
Array.from(row.querySelectorAll('span')).findLast(
(el) => el.textContent?.trim() === role,
)!
const [sam, huber, , jonas, marta] = rows()
expect(badge(marta, 'owner').className).toContain('bg-primary')
expect(badge(marta, 'owner').className).not.toContain('bg-transparent')
expect(badge(jonas, 'admin').className).toContain('bg-transparent')
expect(badge(jonas, 'admin').className).not.toContain('bg-primary')
expect(badge(jonas, '—').className).toContain('text-muted')
expect(badge(sam, '—').className).toContain('text-muted')
expect(badge(huber, 'Paul Huber').className).toContain('text-highlighted')
expect(badge(huber, 'Paul Huber').className).not.toContain('text-muted')
expect(badge(marta, 'you')).toBeTruthy()
expect(rows().filter((row) => badge(row, 'you'))).toEqual([marta])
})
it('styles the same badges and hides the display-name line when empty on cards below md', async () => {
await mountPage({}, false)
const spans = (row: Element) => Array.from(row.querySelectorAll('span'))
const [, huber, , jonas, marta] = rows()
const find = (row: Element, text: string) =>
spans(row).findLast((el) => el.textContent?.trim() === text)
expect(find(marta, 'owner')!.className).toContain('bg-primary')
expect(find(jonas, 'admin')!.className).toContain('bg-transparent')
expect(find(jonas, '—')).toBeUndefined()
expect(find(huber, 'Paul Huber')).toBeTruthy()
expect(find(marta, 'you')).toBeTruthy()
expect(find(jonas, 'you')).toBeUndefined()
})
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',
'sam.oliveira@renew.example admin Pending Created 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired Created 3 Aug 2026',
'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',
])
expect(document.body.querySelector('[data-testid="user-row"]')).toBeNull()
})
@@ -289,7 +356,7 @@ describe('Default — the list loads newest first, with display name, "you" tag
})
describe('Display names in the Users list', () => {
it('shows each display name with its username beside it and the chip with the session name', async () => {
it('shows each saved name, a dash for empty ones, the role and the chip with the session name', async () => {
const fixture = defaultFixture().map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: 'Marta Alves' }
@@ -301,13 +368,14 @@ describe('Display names in the Users list', () => {
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: 'owner',
}
await flushPromises()
expect(rowTexts().slice(2)).toEqual([
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'Marta Alves marta you Accepted 12 Mar 2026',
'ines.duarte@renew.example Inès Duarte admin Accepted 18 Jun 2026',
'jonas@renew.example — admin Accepted 2 Apr 2026',
'marta@renew.example you Marta Alves owner Accepted 12 Mar 2026',
])
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
@@ -336,13 +404,31 @@ describe('Loading — skeleton rows after 150 ms', () => {
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(5)
})
it('draws role and status placeholders on the desktop table skeleton only', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: () => deferred<UserListItem[]>().promise })
vi.advanceTimersByTime(150)
await flushPromises()
const tableBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
expect(tableBars).toHaveLength(25)
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
await mountPage({ list: () => deferred<UserListItem[]>().promise }, false)
vi.advanceTimersByTime(150)
await flushPromises()
const cardBars = document.body.querySelectorAll('[aria-busy="true"] .animate-pulse')
expect(cardBars).toHaveLength(10)
})
})
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(rowTexts()).toEqual(['marta@renew.example you — owner Accepted 12 Mar 2026'])
expect(document.body.textContent).toContain(
'Invite someone so they can sign in with their own account instead of sharing yours.',
)
@@ -350,6 +436,36 @@ describe('Only you — nobody has been invited yet', () => {
})
})
describe('Non-owners cannot invite', () => {
it.each([
{ name: 'admin on desktop', role: 'admin', desktop: true },
{ name: 'admin below md', role: 'admin', desktop: false },
{ name: 'a missing role on desktop', role: null, desktop: true },
{ name: 'an unknown role below md', role: 'guest', desktop: false },
])('renders the list but no Invite user for $name', async ({ role, desktop }) => {
await mountPage({}, desktop, role)
expect(findButton('Invite user')).toBeUndefined()
expect(rows()).toHaveLength(5)
})
it('hides the Only-you hint for an admin', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'admin')
expect(rows()).toHaveLength(1)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
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')
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', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
@@ -480,13 +596,13 @@ describe('Invite sent — a 201 closes the modal, adds a Pending row on top and
expect(listCalls()).toHaveLength(1)
const texts = rowTexts()
expect(texts).toHaveLength(6)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending Today')
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',
'sam.oliveira@renew.example — admin Pending 27 Sep 2026',
'p.huber@gmail.com Paul Huber admin Expired 3 Aug 2026',
'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',
])
expect(rows()[0].className).toContain('bg-elevated')
expect(rows()[1].className).not.toContain('bg-elevated')
@@ -516,7 +632,7 @@ describe('Invite sent to an address whose invite had expired replaces the old ro
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(rowTexts()[0]).toContain('p.huber@gmail.com — admin Pending')
expect(rows()).toHaveLength(5)
})
})
@@ -529,8 +645,8 @@ describe('Invite sent into the Only-you state keeps a coherent list', () => {
const texts = rowTexts()
expect(texts).toHaveLength(2)
expect(texts[0]).toContain('lea.martin@renew.example Pending')
expect(texts[1]).toContain('marta you Accepted')
expect(texts[0]).toContain('lea.martin@renew.example — admin Pending')
expect(texts[1]).toContain('marta@renew.example you')
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
})
})
@@ -603,7 +719,7 @@ describe('Invite: rate limited — a 429 shows a dialog alert and keeps the addr
await clickButton('Send invite')
expect(dialog()).toBeNull()
expect(rowTexts()[0]).toContain('lea.martin@renew.example Pending')
expect(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})
@@ -644,7 +760,7 @@ describe('Retrying after a failure succeeds', () => {
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(rowTexts()[0]).toContain('lea.martin@renew.example — admin Pending')
expect(toastAdd).toHaveBeenCalledTimes(1)
})
})