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

+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'))
})
})