feat: register T1 (pending, verify)

This commit is contained in:
fsylvain committed 2026-09-26 04:56:48 +02:00
1 parent 3c3f7c6194
commit 4e41d60142
29 files changed
+2162 -42

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
onMounted(async () => {
await nextTick()
cancelButton.value?.$el?.focus()
})
function settle(confirmed: boolean) {
if (settled) {
return
}
settled = true
open.value = false
emit('close', confirmed)
}
watch(open, (value) => {
if (!value) {
settle(false)
}
})
async function onConfirm() {
if (pending.value) {
return
}
pending.value = true
error.value = ''
try {
await props.action()
settle(true)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
} finally {
pending.value = false
}
}
function onCancel() {
if (pending.value) {
return
}
settle(false)
}
</script>
<template>
<UModal
v-model:open="open"
:title="title"
:description="description"
:dismissible="!pending"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content:
'max-sm:!inset-x-0 max-sm:!bottom-0 max-sm:!top-auto max-sm:!left-0 max-sm:!right-0 max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:!w-full max-sm:!max-w-none',
}"
>
<template v-if="error" #body>
<UAlert
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
ref="cancelButton"
color="neutral"
variant="outline"
block
class="sm:w-auto"
:disabled="pending"
@click="onCancel"
>
{{ cancelLabel }}
</UButton>
<UButton
:color="confirmColor"
:loading="pending"
:disabled="pending"
block
class="sm:w-auto"
@click="onConfirm"
>
{{ confirmText }}
</UButton>
</div>
</template>
</UModal>
</template>
+3 -1
View File
@@ -6,6 +6,7 @@ interface ResultPanelAction {
color?: 'primary' | 'neutral'
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
to?: string
disabled?: boolean
onClick?: () => void
}
@@ -46,6 +47,7 @@ onMounted(async () => {
</h1>
<p class="text-toned text-sm">{{ description }}</p>
</div>
<slot />
<div class="flex w-full flex-col gap-2">
<UButton
v-for="action in actions"
@@ -55,11 +57,11 @@ onMounted(async () => {
:color="action.color ?? 'primary'"
:variant="action.variant ?? 'solid'"
:to="action.to"
:disabled="action.disabled"
@click="action.onClick"
>
{{ action.label }}
</UButton>
</div>
<slot />
</div>
</template>
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ConfirmDialog from '../ConfirmDialog.vue'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDialog(action: () => Promise<void>) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Cancel your registration?',
description:
'Your account will be deleted. You can register again with the same or a different email.',
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep my account',
pendingLabel: 'Cancelling…',
tone: 'danger',
action,
},
global: {
plugins: [uiPlugin],
},
})
}
function findButtonElement(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButtonElement(text)?.click()
await flushPromises()
}
describe('Confirm cancel — the destructive dialog opens', () => {
it('renders title, description, both buttons, role and starts focus on the cancel button', async () => {
const wrapper = mountDialog(() => new Promise(() => {}))
await flushPromises()
expect(document.body.textContent).toContain('Cancel your registration?')
expect(document.body.textContent).toContain(
'Your account will be deleted. You can register again with the same or a different email.',
)
const cancelButton = findButtonElement('Keep my account')
const confirmButton = findButtonElement('Cancel registration')
expect(cancelButton).toBeTruthy()
expect(confirmButton).toBeTruthy()
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy()
expect(cancelButton).toBe(document.activeElement)
wrapper.unmount()
})
})
describe('Cancelling — the action is in flight and the dialog cannot be dismissed', () => {
it('disables both buttons, shows the pending label and sets aria-busy', async () => {
let resolveAction: (() => void) | null = null
const action = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveAction = resolve
}),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
await flushPromises()
wrapper.unmount()
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('emits close(false) without ever calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Keep my account')
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('Cancelled — resolving the action closes the dialog', () => {
it('emits close(true) once the action resolves', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
wrapper.unmount()
})
})
@@ -129,6 +129,26 @@ describe('useApi global 401/403/429/offline handling', () => {
},
)
it('A 403 with onForbidden passthrough never redirects even when the session is unverified', async () => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await router.push('/login')
const pathBefore = router.currentRoute.value.fullPath
fetchMock.mockResolvedValue(mockResponse(403))
const call = useApi().request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
await expectApiError(call, { status: 403 })
await settle()
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
expect(session.session).toEqual({ verified: false })
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
+4 -2
View File
@@ -24,9 +24,11 @@ export interface MeResult {
}
export type OnUnauthorized = 'redirect' | 'passthrough'
export type OnForbidden = 'redirect' | 'passthrough'
export interface RequestOptions extends RequestInit {
onUnauthorized?: OnUnauthorized
onForbidden?: OnForbidden
}
export const isOffline = ref(false)
@@ -58,7 +60,7 @@ if (typeof window !== 'undefined') {
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', ...init } = options
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
try {
@@ -80,7 +82,7 @@ export function useApi() {
if (response.status === 403) {
const session = useSessionStore()
if (session.session?.verified === false) {
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
throw new ApiError(403)
+6
View File
@@ -0,0 +1,6 @@
export const registrationCancelledToast = {
color: 'success' as const,
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
}
+18 -2
View File
@@ -1,3 +1,5 @@
const overlayBackground = 'bg-(--ui-color-neutral-950)/40'
export const renewUi = {
colors: {
primary: 'ink',
@@ -121,13 +123,27 @@ export const renewUi = {
modal: {
slots: {
content: 'rounded-none shadow-(--shadow-modal) divide-default',
overlay: 'bg-(--ui-color-neutral-950)/40',
overlay: overlayBackground,
},
variants: {
overlay: {
true: {
overlay: overlayBackground,
},
},
},
},
slideover: {
slots: {
content: 'rounded-none shadow-(--shadow-modal)',
overlay: 'bg-(--ui-color-neutral-950)/40',
overlay: overlayBackground,
},
variants: {
overlay: {
true: {
overlay: overlayBackground,
},
},
},
},
toast: {
+128 -2
View File
@@ -1,9 +1,135 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import AuthShell from '@/components/AuthShell.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { useSessionStore } from '@/stores/session'
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const overlay = useOverlay()
const toast = useToast()
const pendingEmail = ref<string | null>(null)
const loggingOut = ref(false)
const cancelOutcome = ref<'cancelled' | 'alreadyVerified' | null>(null)
onMounted(() => {
pendingEmail.value = sessionStorage.getItem('renewcms:pendingEmail')
document.title = 'Check your email · RenewCMS'
})
const description = computed(() =>
pendingEmail.value
? `We sent a verification link to ${pendingEmail.value}. It expires in 2 hours.`
: 'We sent a verification link to the email you registered with. It expires in 2 hours.',
)
async function logOut() {
loggingOut.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
loggingOut.value = false
}
session.clear()
await router.push('/login')
}
async function deleteRegistration() {
try {
await api.request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
cancelOutcome.value = 'cancelled'
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
await session.retry()
cancelOutcome.value = 'alreadyVerified'
return
}
throw new Error(
"Couldn't cancel registration, so it's still pending. Try again in a moment.",
)
}
}
async function cancelRegistration() {
cancelOutcome.value = null
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Cancel your registration?',
description:
'Your account will be deleted. You can register again with the same or a different email.',
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep my account',
pendingLabel: 'Cancelling…',
tone: 'danger',
action: deleteRegistration,
},
})
.open()
if (!confirmed) {
return
}
if (cancelOutcome.value === 'cancelled') {
await router.push('/register')
toast.add(registrationCancelledToast)
return
}
if (cancelOutcome.value === 'alreadyVerified') {
await router.push('/home')
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
}
}
const actions = computed(() => [
{
label: 'Log out',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
onClick: logOut,
},
{
label: 'Cancel registration',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: loggingOut.value,
onClick: cancelRegistration,
},
])
</script>
<template>
<AuthShell title="Check your inbox">
<p>Pending verification placeholder.</p>
<AuthShell title="">
<ResultPanel
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
:description="description"
:actions="actions"
>
<p class="text-toned text-sm">
Opened it on another device? That works too; you'll log in there first. Can't find
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
</p>
</ResultPanel>
</AuthShell>
</template>
+1
View File
@@ -71,6 +71,7 @@ async function onSubmit() {
password: password.value,
}),
})
sessionStorage.setItem('renewcms:pendingEmail', email.value)
await router.push('/register/pending')
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
+371 -2
View File
@@ -1,9 +1,378 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import AuthShell from '@/components/AuthShell.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { registrationCancelledToast } from '@/lib/registrationToasts'
import { useSessionStore } from '@/stores/session'
type VerifyState =
| 'verifying'
| 'noSessionRetrying'
| 'success'
| 'invalid'
| 'noSession'
| 'missing'
interface AlertState {
tone: 'error' | 'info'
icon: string
title: string
description: string
}
const wrongCredentialsAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "That username and password don't match",
description: 'Check both and try again. Passwords are case-sensitive.',
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
}
const cancelFailedAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't cancel registration",
description: 'Something went wrong on the server. Try again in a moment.',
}
const VERIFICATION_SUCCESS_DELAY_MS = 800
const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const toast = useToast()
const retryAfter = useRetryAfter()
const state = ref<VerifyState>('missing')
const invalidActionsDisabled = ref(false)
const cancelAlert = ref<AlertState | null>(null)
const username = ref('')
const password = ref('')
const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() => (state.value === 'noSession' ? 'Log in to finish verifying' : ''))
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
: undefined,
)
const loginSubmitDisabled = computed(
() =>
username.value.length === 0 ||
password.value.length === 0 ||
loginSubmitting.value ||
retryAfter.active.value,
)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
return 'Logging in…'
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
}
return 'Log in and verify'
})
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
}
async function performVerify(code: string) {
try {
await api.request('/v1/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
onUnauthorized: 'passthrough',
onForbidden: 'passthrough',
})
state.value = 'success'
await Promise.all([session.retry(), wait(VERIFICATION_SUCCESS_DELAY_MS)])
await router.push('/home')
toast.add({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
return
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
state.value = 'noSession'
return
}
state.value = 'invalid'
}
}
async function startVerifying(code: string) {
state.value = 'verifying'
await performVerify(code)
}
async function retryVerifying(code: string) {
state.value = 'noSessionRetrying'
await performVerify(code)
}
function handleLoginError(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
loginAlert.value = wrongCredentialsAlert
password.value = ''
focusPassword()
return
}
if (error instanceof ApiError && error.status === 429) {
loginAlert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
loginAlert.value = serverErrorAlert
}
async function onLoginSubmit() {
if (loginSubmitDisabled.value) {
return
}
loginAlert.value = null
loginSubmitting.value = true
try {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
})
const code = typeof route.query.c === 'string' ? route.query.c : ''
await retryVerifying(code)
} catch (error) {
handleLoginError(error)
} finally {
loginSubmitting.value = false
}
}
async function cancelAndStartOver() {
cancelAlert.value = null
invalidActionsDisabled.value = true
try {
await api.request('/v1/auth/register', {
method: 'DELETE',
onForbidden: 'passthrough',
})
await router.push('/register')
toast.add(registrationCancelledToast)
} catch {
cancelAlert.value = cancelFailedAlert
} finally {
invalidActionsDisabled.value = false
}
}
async function logOutFromInvalid() {
invalidActionsDisabled.value = true
try {
await api.request('/v1/auth/logout', { method: 'POST' })
} finally {
invalidActionsDisabled.value = false
}
session.clear()
await router.push('/login')
}
const invalidActions = computed(() => [
{
label: 'Cancel registration and start over',
color: 'primary' as const,
variant: 'solid' as const,
disabled: invalidActionsDisabled.value,
onClick: cancelAndStartOver,
},
{
label: 'Log out',
color: 'neutral' as const,
variant: 'outline' as const,
disabled: invalidActionsDisabled.value,
onClick: logOutFromInvalid,
},
])
function goToLogin() {
router.push('/login')
}
const missingCodeActions = [
{
label: 'Go to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
onMounted(() => {
const code = typeof route.query.c === 'string' ? route.query.c : ''
if (!code) {
state.value = 'missing'
return
}
startVerifying(code)
})
</script>
<template>
<AuthShell title="Verifying your account">
<p>Verify placeholder.</p>
<AuthShell :title="shellTitle" :description="shellDescription">
<template v-if="state === 'invalid' && cancelAlert" #notice>
<UAlert
:color="cancelAlert.tone"
:icon="cancelAlert.icon"
:title="cancelAlert.title"
:description="cancelAlert.description"
variant="soft"
:close="false"
role="alert"
/>
</template>
<template v-else-if="state === 'noSession' && loginAlert" #notice>
<UAlert
:color="loginAlert.tone"
:icon="loginAlert.icon"
:title="loginAlert.title"
:description="loginAlert.description"
variant="soft"
:close="false"
:role="loginAlert.tone === 'error' ? 'alert' : 'status'"
/>
</template>
<template #default="{ controlSize, controlClass }">
<div
v-if="state === 'verifying' || state === 'noSessionRetrying'"
role="status"
class="flex flex-col items-center gap-4 py-10"
>
<UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-primary" />
<p class="text-toned text-sm">Verifying your account…</p>
</div>
<ResultPanel
v-else-if="state === 'success'"
tone="success"
icon="i-lucide-shield-check"
title="Your account is verified"
description="Taking you to your dashboard…"
:actions="[]"
/>
<ResultPanel
v-else-if="state === 'invalid'"
tone="error"
icon="i-lucide-link-2-off"
title="This verification link doesn't work"
description="It may have expired (links last 2 hours) or already been used."
:actions="invalidActions"
>
<p class="text-toned text-sm">
Cancel your registration and start over to get a new link.
</p>
</ResultPanel>
<ResultPanel
v-else-if="state === 'missing'"
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="The verification code is missing. Open the link from your email again, or copy the full address into your browser."
:actions="missingCodeActions"
/>
<UForm
v-else-if="state === 'noSession'"
:state="{ username, password }"
class="flex flex-col gap-4"
@submit="onLoginSubmit"
>
<UFormField label="Username" name="username" :size="controlSize">
<UInput
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="loginSubmitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="loginSubmitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{ base: controlClass }"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="loginSubmitting"
:disabled="loginSubmitDisabled"
>
{{ loginSubmitLabel }}
</UButton>
</UForm>
</template>
</AuthShell>
</template>
+299
View File
@@ -0,0 +1,299 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
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> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
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),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountPending(
handlers: Record<string, () => Response | Promise<Response>> = {},
pendingEmail: string | null = 'marta@renew.example',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
if (pendingEmail === null) {
sessionStorage.removeItem('renewcms:pendingEmail')
} else {
sessionStorage.setItem('renewcms:pendingEmail', pendingEmail)
}
await router.push('/register/pending')
const wrapper = mount(PendingView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text() === label)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
sessionStorage.removeItem('renewcms:pendingEmail')
await router.push('/login')
})
describe('Default — the email is known from a just-completed registration', () => {
it('shows the ResultPanel with the interpolated email', async () => {
const { wrapper } = await mountPending({}, 'marta@renew.example')
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'We sent a verification link to marta@renew.example. It expires in 2 hours.',
)
expect(wrapper.text()).toContain(
"Opened it on another device? That works too; you'll log in there first. Can't find it? Check your spam folder. If the link has expired, cancel your registration and start again.",
)
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(findButton(wrapper, 'Cancel registration')).toBeTruthy()
})
})
describe('Email unknown — arrived by logging in as an unverified account', () => {
it('shows the generic text with no email', async () => {
const { wrapper } = await mountPending({}, null)
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'We sent a verification link to the email you registered with. It expires in 2 hours.',
)
})
})
describe('Logging out', () => {
it('disables both actions while in flight, clears the session and navigates to /login', async () => {
let resolveLogout: (() => void) | null = null
const { wrapper } = await mountPending({
'POST /v1/auth/logout': () =>
new Promise((resolve) => {
resolveLogout = () => resolve(mockResponse(200))
}),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(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'))
})
})
describe('Confirm cancel — the destructive dialog opens', () => {
it('opens ConfirmDialog with the expected props, leaving the ResultPanel shown', async () => {
const { wrapper } = await mountPending()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
expect(overlayMock.create).toHaveBeenCalledTimes(1)
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Cancel your registration?')
expect(props.description).toBe(
'Your account will be deleted. You can register again with the same or a different email.',
)
expect(props.confirmLabel).toBe('Cancel registration')
expect(props.cancelLabel).toBe('Keep my account')
expect(props.pendingLabel).toBe('Cancelling…')
expect(props.tone).toBe('danger')
expect(typeof props.action).toBe('function')
expect(wrapper.text()).toContain('Check your email')
})
})
describe('Cancelling — the DELETE is in flight and the dialog can not be dismissed', () => {
it('sends DELETE /v1/auth/register from the dialog action', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountPending({
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
})
})
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
it('never calls DELETE and keeps the ResultPanel unchanged', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': deleteRegister,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
overlayMock.resolveOpen(false)
await flushPromises()
expect(deleteRegister).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(wrapper.text()).toContain('Check your email')
})
})
describe('Cancelled — 200 navigates to /register with a toast', () => {
it('navigates to /register and shows the cancelled toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(200),
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
})
})
})
describe('Already verified — 403 refreshes the session and navigates to /home with a toast', () => {
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(403),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
const pushSpy = vi.spyOn(router, 'push')
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toEqual({ verified: true })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'info',
icon: 'i-lucide-info',
title: 'Your account is already verified',
description: "There's nothing to cancel.",
})
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
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,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
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()
})
})
@@ -296,4 +296,19 @@ describe('A successful registration goes straight to the pending page', () => {
}),
)
})
it('writes the submitted email to sessionStorage before navigating', async () => {
sessionStorage.removeItem('renewcms:pendingEmail')
const { wrapper } = await mountRegister({
'POST /v1/auth/register': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: false }),
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
})
})
+377
View File
@@ -0,0 +1,377 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
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> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', () => ({
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountVerify(
path: string,
handlers: Record<string, () => Response | Promise<Response>> = {},
beforeMount: () => void = () => {},
) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push(path)
beforeMount()
const wrapper = mount(VerifyView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text() === label)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
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,
})
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
)
})
})
describe('Success — 200 refreshes the session and navigates to /home with a toast', () => {
it('shows the success panel, then navigates after 800ms once session.retry resolves', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
})
expect(wrapper.text()).toContain('Your account is verified')
expect(wrapper.text()).toContain('Taking you to your dashboard…')
expect(router.currentRoute.value.path).toBe('/register/validate')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Your account is verified',
description: 'Welcome to RenewCMS.',
})
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
})
})
describe('Invalid or expired — 403 offers a way to start over', () => {
it('shows the error panel with the note and both actions', async () => {
const pushSpy = vi.spyOn(router, 'push')
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{ 'POST /v1/auth/verify': () => mockResponse(403) },
() => {
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
},
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(wrapper.text()).toContain(
'It may have expired (links last 2 hours) or already been used.',
)
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, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
})
})
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,
})
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'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Registration cancelled',
description: 'You can create the owner account again.',
})
})
})
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
it.each([
{ name: 'a 403 (already verified elsewhere)', handler: () => mockResponse(403) },
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
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,
})
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()
})
})
describe('Log out from the Invalid-or-expired state', () => {
it('clears the session and navigates to /login', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'POST /v1/auth/logout': () => mockResponse(200),
})
const session = useSessionStore()
session.session = { verified: false }
session.status = 'ready'
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('No session — the inline log-in form replaces the spinner', () => {
it('shows the log-in form with the right copy and no forgot-password or footer', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect(wrapper.find('h1').text()).toBe('Log in to finish verifying')
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.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
})
})
describe('No session — submit is disabled until both fields are filled', () => {
it('enables the submit button once both fields have a value', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
})
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
true,
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
})
})
describe('No session — successful login retries verification with the same code', () => {
it('shows the spinner again and resends verify with the same code and passthrough options', async () => {
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),
})
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(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
)
expect(verify).toHaveBeenCalledTimes(2)
})
})
describe('No session — wrong credentials during the inline login', () => {
it('shows the mismatch alert, clears and refocuses the password, and never retries verify', async () => {
const verify = vi.fn(() => mockResponse(401))
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'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('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((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
})
})
describe('No session — too many login attempts', () => {
it('shows the info alert with a live countdown on the submit button', async () => {
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' }),
})
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.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
describe('No session — server or network error during the inline login', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
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,
})
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)
})
})
describe('Missing code — no request is made', () => {
it('renders the incomplete-link panel and navigates to /login', async () => {
const { wrapper, fetchMock } = await mountVerify('/register/validate')
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('This link is incomplete')
expect(wrapper.text()).toContain(
'The verification code is missing. Open the link from your email again, or copy the full address into your browser.',
)
const action = findButton(wrapper, 'Go to log in')
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})