feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
username: string
displayName?: string
email?: string
}>()
const emit = defineEmits<{
logout: []
}>()
const open = defineModel<boolean>('open', { default: false })
const {
colorMode,
name,
initials,
onProfile,
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
function goToProfile() {
open.value = false
navigateToProfile()
}
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
open.value = false
emit('logout')
}
function moveFocus(event: KeyboardEvent) {
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
}
const current = items.indexOf(document.activeElement as HTMLElement)
const lastIndex = items.length - 1
const targets: Record<string, number> = {
ArrowDown: current >= lastIndex ? 0 : current + 1,
ArrowUp: current <= 0 ? lastIndex : current - 1,
Home: 0,
End: lastIndex,
}
const target = targets[event.key]
if (target === undefined) {
return
}
event.preventDefault()
items[target]?.focus()
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:close="false"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
aria-hidden="true"
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
name
}}</span>
<span class="truncate text-muted">{{ email }}</span>
</div>
</div>
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-circle-user"
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<USeparator class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-log-out"
:class="ITEM_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
</UButton>
</div>
</template>
</USlideover>
</template>
+7 -1
View File
@@ -7,7 +7,7 @@ const props = withDefaults(
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
variant?: 'inline' | 'footer' | 'field'
plainText?: boolean
}>(),
{
@@ -113,6 +113,12 @@ const isErrorStatus = computed(() => status.value === 'over' || status.value ===
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<span
v-else-if="variant === 'field'"
class="font-mono text-xs leading-tight"
:class="overBy > 0 ? 'text-error' : 'text-muted'"
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-xs"
+28 -11
View File
@@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
@@ -14,10 +15,12 @@ const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
renderWhileChecking?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
renderWhileChecking: false,
},
)
@@ -63,6 +66,15 @@ const navUi = computed(() => ({
linkLeadingIcon: 'size-4',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
function openAccountSheet() {
sidebarOpen.value = false
accountSheetOpen.value = true
}
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
function onRetry() {
session.retry()
@@ -82,11 +94,10 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
>Skip to content</a
>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
@@ -115,8 +126,11 @@ async function onLogout() {
<template #footer>
<UserMenu
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed"
@logout="onLogout"
@open-sheet="openAccountSheet"
/>
</template>
</UDashboardSidebar>
@@ -164,26 +178,29 @@ async function onLogout() {
<template #body>
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<AppBoot v-if="showBoot" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
<slot />
</div>
</div>
</template>
</UDashboardPanel>
<AccountMenuSheet
v-model:open="accountSheetOpen"
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
/>
</UDashboardGroup>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
</div>
<div
v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
>
<slot name="footer" />
</div>
</section>
</template>
+64 -31
View File
@@ -1,43 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
useColorMode,
type ColorModePreference,
} from '@/composables/useColorMode'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const DESKTOP_QUERY = '(min-width: 64rem)'
const props = defineProps<{
username: string
displayName?: string
email?: string
collapsed: boolean
}>()
const emit = defineEmits<{
logout: []
'open-sheet': []
}>()
const colorMode = useColorMode()
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
const chipActive = computed(() => open.value || onProfile.value)
const triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined,
props.collapsed ? `${name.value} — account menu` : undefined,
)
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
const chipClass = computed(() => [
'h-10 gap-2.5 px-1.5 font-normal',
props.collapsed ? 'justify-center' : 'justify-start',
chipActive.value
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 hover:text-primary-900 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'text-highlighted',
])
function onUpdateOpen(value: boolean) {
if (isDesktop.value) {
open.value = value
return
}
if (value) {
emit('open-sheet')
}
}
const items = computed(() => [
[{ type: 'label' as const, label: props.username }],
[
{
type: 'label' as const,
label: name.value,
slot: 'account-header' as const,
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
icon: 'i-lucide-circle-user',
class: onProfile.value ? 'bg-elevated' : undefined,
onSelect: goToProfile,
},
],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
@@ -51,6 +73,7 @@ const items = computed(() => [
[
{
label: 'Log out',
icon: 'i-lucide-log-out',
onSelect: () => emit('logout'),
},
],
@@ -58,17 +81,21 @@ const items = computed(() => [
</script>
<template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
<UDropdownMenu
:open="open"
:items="items"
:content="{ side: 'top', align: 'start' }"
:ui="{ content: 'w-56 p-1' }"
@update:open="onUpdateOpen"
>
<UButton
color="neutral"
variant="ghost"
block
:class="[
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
collapsed ? 'justify-center' : 'justify-start',
]"
title="Account"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
@@ -78,12 +105,18 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback:
'text-[0.6875rem] font-semibold text-primary-900',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
<span v-if="!collapsed" class="truncate">{{ username }}</span>
<span v-if="!collapsed" class="truncate font-medium">{{ name }}</span>
</UButton>
<template #account-header>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
</UDropdownMenu>
</template>
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
})
})
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
describe('The field variant is a bare mono count', () => {
function mountField(value: number) {
return mount(CharacterCounter, {
props: { value, max: 60, variant: 'field' },
global: { plugins: [uiPlugin] },
})
}
it.each([
[0, 'text-muted'],
[60, 'text-muted'],
[61, 'text-error'],
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
const wrapper = mountField(value)
expect(wrapper.text()).toBe(`${value} / 60`)
expect(wrapper.classes()).toContain(colorClass)
expect(wrapper.classes()).toContain('font-mono')
expect(wrapper.find('svg').exists()).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
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 { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
}
})
async function mountShell(path: string, username = 'marta') {
interface ShellOptions {
username?: string
displayName?: string
email?: string
width?: number
}
let mountedShell: VueWrapper | null = null
async function mountShell(path: string, options: ShellOptions = {}) {
const {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
plugins: [router, uiPlugin],
},
})
mountedShell = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
mountedShell?.unmount()
mountedShell = null
await router.push('/login')
document.body.innerHTML = ''
})
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
describe('The account chip shows the display name and no item is current on /profile', () => {
it('shows the display name and its initials in the sidebar chip', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
it('keeps the collapsed accessible name on the display name', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const collapseButton = document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
collapseButton.click()
await flushPromises()
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the four navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
describe('Account menu: opened from the drawer on mobile', () => {
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const accountRow = document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
expect(accountRow).not.toBeNull()
accountRow.click()
await flushPromises()
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
expect(sheets).toHaveLength(1)
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
expect(
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
).not.toBeNull()
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(item.className).toContain('h-12'),
)
})
it('navigates to /profile and closes the sheet from Profile', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const profile = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profile.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('logs out from the sheet', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
logOut.click()
await flushPromises()
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
'/v1/auth/logout',
expect.objectContaining({ method: 'POST' }),
)
expect(router.currentRoute.value.path).toBe('/login')
})
})
+259 -130
View File
@@ -1,175 +1,304 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { useSessionStore } from '@/stores/session'
import router from '@/router'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
function mountMenu(props: { username: string; collapsed: boolean }) {
interface MenuProps {
username: string
displayName?: string
email?: string
collapsed: boolean
}
const baseProps: MenuProps = {
username: 'marta',
displayName: '',
email: 'marta@renew.example',
collapsed: false,
}
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
return mount(UserMenu, {
attachTo: document.body,
props,
global: {
plugins: [uiPlugin],
},
props: { ...baseProps, ...props },
global: { plugins: [router, uiPlugin] },
})
}
function findTriggerButton() {
return document.body.querySelector('button')
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
async function openMenu() {
findTriggerButton()?.click()
chip().click()
await flushPromises()
}
function findMenuItemsByRole(role: string) {
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
function menu() {
return document.body.querySelector('[role="menu"]') as HTMLElement | null
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find(
(el) => el.textContent?.trim() === label,
)
function itemsIn(container: ParentNode) {
return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
) as HTMLElement[]
}
function itemLabels() {
return itemsIn(menu()!).map((el) => el.textContent?.trim())
}
function findItem(label: string) {
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
setViewportWidth(DESKTOP_WIDTH)
})
afterEach(() => {
afterEach(async () => {
document.body.innerHTML = ''
await router.push('/login')
})
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('The chip opens the account menu instead of logging out', () => {
it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
const wrapper = await mountMenu()
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('MA')
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
expect(wrapper.text()).toContain('marta')
expect(chip().getAttribute('aria-haspopup')).toBe('menu')
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(chip().getAttribute('title')).toBe('Account')
expect(chip().textContent).toContain('marta')
expect(wrapper.emitted('logout')).toBeUndefined()
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
expect(groups[2].textContent?.trim()).toBe('Log out')
wrapper.unmount()
})
it('derives initials from the first letter of the first two words for a multi-word username', () => {
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('JD')
wrapper.unmount()
})
it('checks exactly the item matching the current color-mode preference', async () => {
const colorMode = useColorMode()
colorMode.preference = 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
expect(chip().getAttribute('aria-expanded')).toBe('true')
expect(menu()).not.toBeNull()
expect(wrapper.emitted('logout')).toBeUndefined()
})
})
describe('Selecting a color-mode item updates the preference and its checked state', () => {
it.each([
['system', 'light'],
['light', 'dark'],
['dark', 'system'],
])('moves from %s to %s', async (from, to) => {
const colorMode = useColorMode()
colorMode.preference = from as 'system' | 'light' | 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
await mountMenu()
await openMenu()
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('emits logout when the user activates Log out', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
expect(wrapper.emitted('logout')).toBeTruthy()
wrapper.unmount()
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
})
it('shows the display name in the header and the chip when there is one', async () => {
await mountMenu({ displayName: 'Marta Alves' })
await openMenu()
expect(chip().textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('marta@renew.example')
})
it('does not highlight Profile away from /profile', async () => {
await mountMenu()
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
})
it('gives the chip the sand fill only while the menu is open', async () => {
await mountMenu()
expect(chip().className).not.toContain('bg-secondary-200')
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().className).toContain('text-primary-900')
})
})
wrapper.unmount()
describe('The chip is sand while on /profile even with the menu closed', () => {
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
await mountMenu({}, '/profile')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().getAttribute('aria-expanded')).toBe('false')
await router.push('/home')
await flushPromises()
expect(chip().className).not.toContain('bg-secondary-200')
})
})
describe('Closing the menu', () => {
it('closes on Escape and returns focus to the chip', async () => {
await mountMenu()
await openMenu()
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(chip())
})
it('closes on a click outside', async () => {
await mountMenu()
await openMenu()
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
})
it('moves between items with the arrow keys', async () => {
await mountMenu()
await openMenu()
const first = itemsIn(menu()!)[0]!
first.focus()
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
})
})
describe('Menu items', () => {
it('navigates to /profile and closes the menu from Profile', async () => {
await mountMenu()
await openMenu()
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('highlights Profile on /profile and only closes when activated there', async () => {
await mountMenu({}, '/profile')
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('emits logout from Log out', async () => {
const wrapper = await mountMenu()
await openMenu()
activate(findItem('Log out'))
await flushPromises()
expect(wrapper.emitted('logout')).toHaveLength(1)
})
})
describe('Theme choice stays in the menu', () => {
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountMenu()
await openMenu()
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
activate(findItem('Dark'))
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(menu()).toBeNull()
await openMenu()
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
})
})
describe('Avatar initials', () => {
it.each([
['marta', '', 'MA', 'marta'],
['marta', 'Marta Alves', 'MA', 'Marta Alves'],
['marta', 'Marta Alves Costa', 'MA', 'Marta Alves Costa'],
['ines', 'Inès Duarte', 'ID', 'Inès Duarte'],
['elodie', 'Élodie Martin', 'ÉM', 'Élodie Martin'],
['marta', 'Marta', 'MA', 'Marta'],
['m.alves', '', 'M.', 'm.alves'],
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
await mountMenu({ username, displayName })
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
expect(chip().textContent).toContain(shown)
await openMenu()
expect(menu()!.textContent).toContain(shown)
})
})
describe('The collapsed rail keeps the menu and an accessible name', () => {
it('shows only the avatar, names the chip and opens the same menu', async () => {
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
expect(chip().textContent).not.toContain('Marta Alves')
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
})
})
describe('Below lg the chip hands the menu to the bottom sheet', () => {
it('emits open-sheet instead of opening the popover', async () => {
setViewportWidth(MOBILE_WIDTH)
const wrapper = await mountMenu()
await openMenu()
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
expect(menu()).toBeNull()
})
})
+30
View File
@@ -0,0 +1,30 @@
const REM_PX = 16
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
if (!match) {
return null
}
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
}
export function setViewportWidth(width: number) {
window.matchMedia = ((query: string) => {
const min = widthLimit(query, 'min-width')
const max = widthLimit(query, 'max-width')
const matches = (min === null || width >= min) && (max === null || width <= max)
return {
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}
}) as unknown as typeof window.matchMedia
}
export const DESKTOP_WIDTH = 1440
export const MOBILE_WIDTH = 390
+45 -15
View File
@@ -5,11 +5,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(session.session).toEqual({ verified: false })
})
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
expect(isOffline.value).toBe(false)
})
})
describe('getMe maps the account fields', () => {
it('maps email and display_name onto email and displayName', async () => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: 'Marta Alves',
}),
)
await expect(useApi().getMe()).resolves.toEqual({
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: 'Marta Alves',
})
})
it.each([
['absent', undefined],
['null', null],
])('reads a %s display_name as an empty string', async (_label, value) => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: value,
}),
)
const me = await useApi().getMe()
expect(me.displayName).toBe('')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import { accountInitials, accountName } from '@/lib/accountName'
const PROFILE_PATH = '/profile'
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
const route = useRoute()
const router = useRouter()
const colorMode = useColorMode()
const name = computed(() => accountName(account().username, account().displayName))
const initials = computed(() => accountInitials(account().username, account().displayName))
const onProfile = computed(() => route.path === PROFILE_PATH)
function goToProfile() {
if (!onProfile.value) {
router.push(PROFILE_PATH)
}
}
return { colorMode, name, initials, onProfile, goToProfile }
}
+16 -5
View File
@@ -25,6 +25,8 @@ export interface MeResult {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -127,11 +129,20 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
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 }
const body = await request<{
id?: number
is_verified: boolean
username?: string
email?: string
display_name?: string | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
verified: Boolean(body.is_verified),
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
}
}
return { getMe, request }
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { accountInitials, accountName } from '@/lib/accountName'
describe('accountName', () => {
it.each([
['marta', '', 'marta'],
['marta', 'Marta Alves', 'Marta Alves'],
['marta', undefined, 'marta'],
['', '', ''],
])('shows %s with display name %j as %s', (username, display, expected) => {
expect(accountName(username, display)).toBe(expected)
})
})
describe('Avatar initials', () => {
it.each([
['marta', '', 'MA'],
['marta', 'Marta Alves', 'MA'],
['marta', 'Marta Alves Costa', 'MA'],
['ines', 'Inès Duarte', 'ID'],
['elodie', 'Élodie Martin', 'ÉM'],
['marta', 'Marta', 'MA'],
['m.alves', '', 'M.'],
['marta', ' Marta Alves ', 'MA'],
['', '', ''],
])('gives %s with display name %j the initials %s', (username, display, expected) => {
expect(accountInitials(username, display)).toBe(expected)
})
it('counts an emoji as one character', () => {
expect(accountInitials('x', '😀 Smile')).toBe('😀S')
})
})
+22
View File
@@ -0,0 +1,22 @@
const WHITESPACE = /\s+/
function firstCharacters(value: string, count: number) {
return Array.from(value).slice(0, count).join('')
}
export function accountName(username: string, displayName?: string | null) {
return displayName ? displayName : username
}
export function accountInitials(username: string, displayName?: string | null) {
const words = (displayName ?? '').trim().split(WHITESPACE).filter(Boolean)
if (words.length >= 2) {
return words
.slice(0, 2)
.map((word) => firstCharacters(word, 1))
.join('')
.toUpperCase()
}
const source = words[0] ?? username
return firstCharacters(source, 2).toUpperCase()
}
+5
View File
@@ -76,6 +76,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
meta: { auth: 'verified', title: 'Profile' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
+28
View File
@@ -18,3 +18,31 @@ describe('session store clear action', () => {
expect(session.status).toBe('ready')
})
})
describe('session store setDisplayName action', () => {
it('replaces the session display name with the given value', () => {
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.setDisplayName('Marta Alves')
expect(session.session?.displayName).toBe('Marta Alves')
})
it.each([[null], [undefined]])('reads %s as an empty display name', (value) => {
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: 'Marta Alves' }
session.setDisplayName(value)
expect(session.session?.displayName).toBe('')
})
it('does nothing without a session', () => {
const session = useSessionStore()
session.setDisplayName('Marta Alves')
expect(session.session).toBeNull()
})
})
+9 -1
View File
@@ -8,6 +8,8 @@ export interface Session {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
}
export const useSessionStore = defineStore('session', () => {
@@ -53,5 +55,11 @@ export const useSessionStore = defineStore('session', () => {
status.value = 'ready'
}
return { status, session, check, retry, clear }
function setDisplayName(displayName: string | null | undefined) {
if (session.value) {
session.value = { ...session.value, displayName: displayName ?? '' }
}
}
return { status, session, check, retry, clear, setDisplayName }
})
+3 -2
View File
@@ -7,6 +7,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
const SKELETON_DELAY_MS = 150
@@ -37,8 +38,8 @@ function greetingForHour(hour: number) {
const greeting = computed(() => {
const timeOfDayGreeting = greetingForHour(new Date().getHours())
const username = session.session?.username
return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
const name = accountName(session.session?.username ?? '', session.session?.displayName)
return name ? `${timeOfDayGreeting}, ${name}` : timeOfDayGreeting
})
const subtitle = computed(() =>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSessionStore, type Session } from '@/stores/session'
const DISPLAY_NAME_MAX = 60
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
const RESET_FALLBACK_SECONDS = 300
const PASSWORD_MASK = '••••••••••••'
const SKELETON_CARDS = [
{ titleWidth: 'w-30', inputs: 1 },
{ titleWidth: 'w-22.5', inputs: 2 },
{ titleWidth: 'w-25', inputs: 1 },
]
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
const session = useSessionStore()
const toast = useToast()
const overlay = useOverlay()
const { request } = useApi()
const retryAfter = useRetryAfter()
const account = ref<Session | null>(session.session)
const field = ref('')
const saving = ref(false)
const saveFailed = ref(false)
const serverLimitError = ref(false)
const resetState = ref<ResetState>('idle')
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
watch(
() => session.session,
(value) => {
if (value) {
account.value = value
}
},
)
watch(
account,
(value, previous) => {
if (value && !previous) {
field.value = value.displayName ?? ''
}
},
{ immediate: true },
)
watch(field, () => {
serverLimitError.value = false
})
watch(retryAfter.remaining, (value) => {
if (value === 0 && resetState.value === 'limited') {
resetState.value = 'idle'
}
})
const username = computed(() => account.value?.username ?? '')
const email = computed(() => account.value?.email ?? '')
const savedName = computed(() => account.value?.displayName ?? '')
const draft = computed(() => field.value.trim())
const count = computed(() => Array.from(draft.value).length)
const dirty = computed(() => draft.value !== savedName.value)
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
const showUnsaved = computed(() => dirty.value && !saving.value)
const statusText = computed(() => {
if (showUnsaved.value) {
return 'Unsaved changes'
}
return savedName.value
? `You appear as “${savedName.value}”.`
: `You appear as “${username.value}”, your username.`
})
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
const resetNotes = {
sent: {
color: 'success',
icon: 'i-lucide-mail-check',
title: 'Reset link sent',
},
limited: {
color: 'warning',
icon: 'i-lucide-clock-alert',
title: 'Too many requests',
description: "You've asked for several links recently. Wait a few minutes, then try again.",
},
error: {
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
},
} as const
const resetNote = computed(() => {
if (resetState.value === 'sent') {
return {
...resetNotes.sent,
description: `Check ${email.value}. The link works once, within 1 hour.`,
}
}
if (resetState.value === 'limited' || resetState.value === 'error') {
return resetNotes[resetState.value]
}
return null
})
const resetButtonLabel = computed(() => {
if (resetState.value === 'sending') {
return 'Sending…'
}
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
})
const resetBusy = computed(() => resetState.value === 'sending')
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
function focusField() {
nextTick(() => inputRef.value?.inputRef?.focus())
}
function announceSaved(displayName: string) {
toast.add({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: displayName ? 'Display name saved' : 'Display name removed',
description: displayName
? `You now appear as ${displayName} across RenewCMS and in emails.`
: `You now appear as ${username.value}, your username.`,
})
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
async function onSave() {
if (saving.value || !saveEnabled.value) {
return
}
saving.value = true
saveFailed.value = false
serverLimitError.value = false
try {
const updated = await request<{ display_name?: string | null }>('/v1/user/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: draft.value }),
})
const displayName = updated.display_name ?? ''
session.setDisplayName(displayName)
field.value = displayName
announceSaved(displayName)
} catch (error) {
if (isHandledGlobally(error)) {
return
}
if (
error instanceof ApiError &&
error.status === 400 &&
error.body === DISPLAY_NAME_LIMIT_BODY
) {
serverLimitError.value = true
} else {
saveFailed.value = true
}
focusField()
} finally {
saving.value = false
}
}
async function onSendReset() {
if (resetDisabled.value) {
return
}
resetState.value = 'sending'
try {
await request('/v1/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
resetState.value = 'sent'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
resetState.value = 'limited'
return
}
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
}
}
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || !dirty.value) {
return true
}
const keepEditing = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: "Your display name change hasn't been saved.",
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !keepEditing
})
</script>
<template>
<DashboardShell render-while-checking>
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
<p class="text-muted">Your account on this RenewCMS workspace.</p>
</div>
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
<div
v-for="(card, cardIndex) in SKELETON_CARDS"
:key="cardIndex"
data-testid="profile-skeleton-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
>
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
<USkeleton
v-for="inputIndex in card.inputs"
:key="inputIndex"
class="h-10 rounded-none bg-accented"
/>
</div>
</div>
<template v-else>
<UForm :state="{ displayName: field }" @submit="onSave">
<ProfileCard
title="Display name"
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
>
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="SAVE_FAILED_COPY"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField name="displayName" :error="showFieldError">
<template #label>
Display name <span class="font-normal text-muted">(optional)</span>
</template>
<UInput
ref="inputRef"
v-model="field"
placeholder="e.g. Marta Alves"
autocomplete="off"
:disabled="saving"
class="w-full"
:ui="inputUi"
@keydown.enter.prevent="onSave"
/>
<div class="mt-1.5 flex items-start gap-3">
<span
v-if="showFieldError"
data-testid="display-name-error"
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ DISPLAY_NAME_LIMIT_COPY }}
</span>
<span v-else class="flex-1" />
<CharacterCounter
data-testid="display-name-counter"
variant="field"
:value="count"
:max="DISPLAY_NAME_MAX"
/>
</div>
</UFormField>
<template #footer>
<span
data-testid="display-name-status"
class="min-w-0 flex-1 text-xs/[18px]"
:class="showUnsaved ? 'text-warning' : 'text-muted'"
>{{ statusText }}</span
>
<UButton
type="submit"
:loading="saving"
:disabled="!saveEnabled && !saving"
class="h-11 shrink-0 px-3.5 lg:h-8"
>
{{ saving ? 'Saving…' : 'Save' }}
</UButton>
</template>
</ProfileCard>
</UForm>
<ProfileCard
title="Account"
description="Set when your account was created. You log in with your username, even after adding a display name."
>
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<UFormField label="Username">
<UInput
:model-value="username"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UFormField label="Email">
<UInput
:model-value="email"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
</div>
</ProfileCard>
<ProfileCard
title="Password"
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<UFormField label="Password" class="min-w-0 flex-1">
<UInput
:model-value="PASSWORD_MASK"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UButton
color="neutral"
variant="outline"
:icon="resetBusy ? undefined : 'i-lucide-mail'"
:loading="resetBusy"
:disabled="resetDisabled"
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
@click="onSendReset"
>
{{ resetButtonLabel }}
</UButton>
</div>
<UAlert
v-if="resetNote"
data-testid="reset-note"
role="status"
variant="soft"
:color="resetNote.color"
:icon="resetNote.icon"
:title="resetNote.title"
:description="resetNote.description"
:close="false"
/>
</ProfileCard>
</template>
</div>
</DashboardShell>
</template>
+52
View File
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
})
})
describe('The greeting names the person by display name, else username', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
{ displayName: '', name: 'marta' },
{ displayName: 'Marta Alves', name: 'Marta Alves' },
])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`)
})
it('shows a display name saved after the page was mounted without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
session.setDisplayName('Marta Alves')
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves')
})
})
describe('The greeting handles a missing username', () => {
it('renders the plain greeting without a stray comma when the session has no username', async () => {
vi.useFakeTimers()
+32 -56
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -40,17 +34,15 @@ function fetchRouter(
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
},
)
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
@@ -148,24 +140,16 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
findButton(wrapper, 'Cancel registration')
?.element as HTMLButtonElement
).disabled,
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -310,23 +289,20 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'rejects the action instead of navigating for $name',
async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
},
)
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
})
})
+872
View File
@@ -0,0 +1,872 @@
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 App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
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>
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
useOverlay: () => overlayMock,
}
})
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
const MARTA: Session = {
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const DISPLAY_NAME_PATH = '/v1/user/me'
const RESET_PATH = '/v1/auth/password-reset'
const LOGOUT_PATH = '/v1/auth/logout'
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
type Handler = (init?: RequestInit) => Promise<unknown>
interface Mounted {
wrapper: VueWrapper
request: ReturnType<typeof vi.fn>
getMe: ReturnType<typeof vi.fn>
}
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
interface MountOptions {
session?: Session | null
status?: 'idle' | 'ready'
getMe?: () => Promise<Session>
handlers?: Record<string, Handler>
path?: string
}
let mountedWrapper: VueWrapper | null = null
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = session ? { ...session } : null
store.status = status
const request = vi.fn((requestPath: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${requestPath}`
const handler = handlers[key]
if (handler) {
return handler(init)
}
if (requestPath === LOGOUT_PATH) {
return Promise.resolve(undefined)
}
return Promise.resolve([])
})
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
await router.push(path)
const wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
mountedWrapper = wrapper
await flushPromises()
return { wrapper, request, getMe }
}
function displayNameField() {
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
}
async function typeInto(value: string) {
const input = displayNameField()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function pressEnter() {
displayNameField().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
}
function buttonByText(pattern: RegExp) {
return Array.from(document.body.querySelectorAll('button')).find((button) =>
pattern.test(button.textContent?.trim() ?? ''),
) as HTMLButtonElement | undefined
}
function saveButton() {
return buttonByText(/^(Save|Saving…)$/)!
}
function resetButton() {
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
}
function byTestId(id: string) {
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
}
function counter() {
return byTestId('display-name-counter')!
}
function footer() {
return byTestId('display-name-status')!
}
function isEnabled(button: HTMLButtonElement) {
return !button.disabled
}
async function clickSave() {
saveButton().click()
await flushPromises()
}
async function clickReset() {
resetButton().click()
await flushPromises()
}
function patchBodies(request: ReturnType<typeof vi.fn>) {
return request.mock.calls
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
}
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
return request.mock.calls.filter(
([requestPath, init]) =>
requestPath === path &&
(method === undefined || (init as RequestInit)?.method === method),
)
}
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function savedResponse(displayName: string) {
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
}
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
beforeEach(() => {
toastAdd = vi.fn()
overlayMock = createOverlayMock()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Route guards for /profile', () => {
it('renders ProfilePage for a resolved, verified session', async () => {
await mountApp()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
})
it('redirects a resolved, unverified session to /register/pending', async () => {
await mountApp({ session: { ...MARTA, verified: false } })
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
await mountApp({
session: null,
status: 'idle',
getMe: () => Promise.reject(new ApiError(401)),
})
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
})
})
describe('Default: the usual first visit with no display name', () => {
it('shows the header, the empty field, the account details and the reset button', async () => {
const { request } = await mountApp()
const text = document.body.textContent ?? ''
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
expect(text).toContain('Your account on this RenewCMS workspace.')
expect(displayNameField().value).toBe('')
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
expect(counter().textContent?.trim()).toBe('0 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(isEnabled(saveButton())).toBe(false)
expect(text).toContain('Display name (optional)')
const accountInputs = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
)
expect(accountInputs.map((input) => input.value)).toEqual([
'marta',
'marta@renew.example',
'••••••••••••',
])
expect(text).toContain(
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
expect(request).not.toHaveBeenCalled()
})
it('marks none of the four navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the three cards in order Display name, Account, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Password'])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
status: 'idle',
getMe: () => pending.promise,
})
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
pending.resolve({ ...MARTA })
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(counter().textContent?.trim()).toBe('0 / 60')
})
})
describe('Opened from the menu, the cached user renders at once', () => {
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
const { getMe } = await mountApp({ path: '/home' })
chip().click()
await flushPromises()
const profileItem = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
activate(profileItem)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(getMe).not.toHaveBeenCalled()
})
})
describe('Editing: typing turns Save on', () => {
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(true)
expect(request).not.toHaveBeenCalled()
})
})
describe('The counter counts Unicode characters after trimming', () => {
it.each([
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
await mountApp()
await typeInto(value)
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
expect(counter().classList.contains(colorClass)).toBe(true)
expect(isEnabled(saveButton())).toBe(enabled)
})
})
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
it('shows the error, ignores Enter and recovers once shortened', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
expect(counter().textContent?.trim()).toBe('62 / 60')
expect(counter().classList.contains('text-error')).toBe(true)
const error = byTestId('display-name-error')!
expect(error.textContent).toContain('Use 60 characters or fewer.')
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(false)
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
await typeInto('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(isEnabled(saveButton())).toBe(true)
})
})
describe('Dirty is measured on the trimmed draft against the saved name', () => {
it.each([
['', ' ', false, 'You appear as “marta”, your username.'],
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
['Marta Alves', '', true, 'Unsaved changes'],
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
])(
'saved %j and typed %j gives Save enabled: %s and %s',
async (saved, typed, enabled, text) => {
await mountApp({ session: { ...MARTA, displayName: saved } })
await typeInto(typed)
expect(isEnabled(saveButton())).toBe(enabled)
expect(footer().textContent?.trim()).toBe(text)
},
)
})
describe('Enter saves when Save is enabled', () => {
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
pressEnter()
await flushPromises()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
})
it('does nothing when the draft is not dirty', async () => {
const { request } = await mountApp()
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
})
})
describe('Saving: the field is disabled and a second submit is ignored', () => {
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
const pending = deferred()
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
expect(displayNameField().disabled).toBe(true)
expect(saveButton().textContent?.trim()).toBe('Saving…')
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
pressEnter()
saveButton().click()
await flushPromises()
expect(patchBodies(request)).toHaveLength(1)
})
})
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
it('replaces the session name, syncs the field, and raises the success toast', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
expect(isEnabled(saveButton())).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name saved',
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
})
expect(request).toHaveBeenCalledTimes(1)
})
it('opens no leave confirmation when navigating away afterwards', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
})
describe("The server's value wins over what was typed", () => {
it('uses the response display_name in the field, the chip and the toast', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(displayNameField().value).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
})
})
describe('Clearing the display name goes back to the username', () => {
it('sends an empty display_name and shows the username with the removed toast', async () => {
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
})
await typeInto('')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: '' }])
expect(chip().textContent).toContain('marta')
expect(chip().textContent).not.toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name removed',
description: 'You now appear as marta, your username.',
})
})
})
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the alert, keeps the value and retries with the same body',
async (_label, failure) => {
const attempts = [failure, () => savedResponse('Marta Alves')]
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
const alert = document.body.querySelector('[role="alert"]')!
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(isEnabled(saveButton())).toBe(true)
expect(useSessionStore().session?.displayName).toBe('')
expect(toastAdd).not.toHaveBeenCalled()
await clickSave()
expect(patchBodies(request)).toEqual([
{ display_name: 'Marta Alves' },
{ display_name: 'Marta Alves' },
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
},
)
it('hides the alert while the retry is in flight', async () => {
const retry = deferred()
const attempts = [SERVER_ERROR, () => retry.promise]
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
await clickSave()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(saveButton().textContent?.trim()).toBe('Saving…')
})
})
describe('A 400 from the API', () => {
it('shows the field error and disables Save for the display-name message', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
expect(isEnabled(saveButton())).toBe(false)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('shows the Save failed alert and keeps the text for any other 400', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
SAVE_FAILED_COPY,
)
expect(displayNameField().value).toBe('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
})
})
describe('Leaving with unsaved changes asks for confirmation', () => {
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
await mountApp()
await typeInto('Marta Alves')
const first = router.push('/users')
await flushPromises()
const props = overlayMock.getProps()!
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe("Your display name change hasn't been saved.")
expect(props.confirmLabel).toBe('Keep editing')
expect(props.cancelLabel).toBe('Discard changes')
expect(props.tone).toBe('neutral')
overlayMock.resolveOpen(true)
await first
expect(router.currentRoute.value.path).toBe('/profile')
expect(displayNameField().value).toBe('Marta Alves')
const second = router.push('/users')
await flushPromises()
overlayMock.resolveOpen(false)
await second
expect(router.currentRoute.value.path).toBe('/users')
expect(useSessionStore().session?.displayName).toBe('')
})
it('opens nothing when nothing is unsaved', async () => {
await mountApp()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
it('opens the dialog when Log out is used with a dirty draft', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
chip().click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
activate(logOut)
await flushPromises()
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
expect(overlayMock.create).toHaveBeenCalledTimes(1)
})
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
authRedirectPending.value = true
router.push({
path: '/login',
query: { redirect: '/profile', reason: 'expired' },
})
return Promise.reject(new ApiError(401))
},
},
})
await typeInto('Marta Alves')
await clickSave()
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
})
})
describe('The Home greeting follows a display name saved on /profile', () => {
it('shows the saved name on /home without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/home')
await flushPromises()
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
'Good morning, Marta Alves',
)
expect(chip().textContent).toContain('Marta Alves')
vi.restoreAllMocks()
})
})
describe('Reset: sending, sent and Send again', () => {
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
const pending = deferred()
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
})
await clickReset()
const [call] = callsTo(request, RESET_PATH, 'POST')
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
email: 'marta@renew.example',
})
expect(resetButton().textContent?.trim()).toBe('Sending…')
expect(resetButton().disabled).toBe(true)
expect(byTestId('reset-note')).toBeNull()
expect(chip().textContent).toContain('Marta Alves')
})
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
const second = deferred()
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
const { request } = await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
expect(resetButton().textContent?.trim()).toBe('Send again')
expect(resetButton().disabled).toBe(false)
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Reset link sent')
expect(note.textContent).toContain(
'Check marta@renew.example. The link works once, within 1 hour.',
)
await clickReset()
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
expect(byTestId('reset-note')).toBeNull()
second.resolve({ message: 'ok' })
await flushPromises()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
await router.push('/home')
await flushPromises()
await router.push('/profile')
await flushPromises()
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
})
})
describe('Reset: too many requests disables the button for the Retry-After window', () => {
it.each([
['a Retry-After of 42', 42, 42],
['a Retry-After of 300', 300, 300],
['no Retry-After header', undefined, 300],
['a Retry-After of 0', 0, 300],
])(
'with %s the button is enabled again after %i seconds',
async (_label, retryAfter, seconds) => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountApp({
handlers: {
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
},
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Too many requests')
expect(note.textContent).toContain(
"You've asked for several links recently. Wait a few minutes, then try again.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime((seconds - 1) * 1000)
await flushPromises()
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime(1000)
await flushPromises()
expect(resetButton().disabled).toBe(false)
expect(byTestId('reset-note')).toBeNull()
},
)
})
describe('Reset: failed leaves the button pressable', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the error note and recovers on a successful retry',
async (_label, failure) => {
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain("Couldn't send the link")
expect(note.textContent).toContain('Check your connection and try again.')
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(false)
await clickReset()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
expect(resetButton().textContent?.trim()).toBe('Send again')
},
)
})
+26
View File
@@ -288,6 +288,32 @@ 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 () => {
const fixture = defaultFixture().map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: 'Marta Alves' }
: item,
)
await mountPage({ list: () => Promise.resolve(fixture) })
useSessionStore().session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
}
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',
])
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
})
})
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'] })
+79 -173
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -81,16 +75,11 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
})
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
})
})
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the failure alert and keeps the panel for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
},
)
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain(
"This verification link doesn't work",
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
},
)
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
})
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(
wrapper.find('input[placeholder="Your username"]').exists(),
).toBe(true)
expect(
wrapper.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
},
)
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('wrong-password')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '30' }),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the network/server alert and keeps the fields for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
},
)
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash2026')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(false)
},
)
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
})
})
describe('Missing code — no request is made', () => {
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})