feat: login page T0

This commit is contained in:
fsylvain committed 2026-09-25 20:15:29 +02:00
1 parent 12faab7f92
commit 001f9f4774
5 files changed
+824 -2

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
Feature: Log in screen
As a returning RenewCMS user
I want a /login screen that authenticates me against POST /v1/auth/login, shows why a previous
attempt or session failed, and offers to create the owner account only while registration is
open
So that I can get back into the back office from any entry point (a direct visit, an expired
session, a completed password reset) with copy that matches what actually happened
Background:
Given /login is a public route (meta.auth "public") rendered as LoginPage inside AuthShell,
per docs/design/routes.md
And on mount, LoginPage calls GET /v1/auth/registration to learn whether registration is open
And the form has a Username field (placeholder "Your username") and a Password field (a
password input with a show/hide toggle, autocomplete "current-password", and a
"Forgot password?" link), per docs/design/components.md
And client-side validation runs on submit before any request is sent: Username is required
with "Use at least 3 characters." under 3 characters, and Password is required with
"Enter your password." when empty
And a valid submit calls POST /v1/auth/login with { username, password }
And on a 200 response, LoginPage re-resolves the session (GET /v1/auth/me, per
features/nuxt_ui_shell_foundation.feature and features/api_client_global_error_handling.feature)
and then navigates: to the original ?redirect= target if present, else to /home when the
session is verified, or to /register/pending when it is not
And a 403 response means the username/password pair is wrong, since the API never reveals
which field was wrong
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed as
a live countdown on the submit button; the button re-enables when the countdown reaches zero
And a 500 response, or a request that never reaches the server, is surfaced as a network/server
error; the fields keep whatever the user typed
Scenario: Default — registration is closed
Given GET /v1/auth/registration resolves 200 with { "open": false }
When the user navigates to /login
Then the heading is "Log in"
And the description is "Welcome back. Log in to manage your articles."
And the Username field shows placeholder "Your username" with no value
And the Password field shows no value
And the submit button reads "Log in"
And no "Create the owner account" footer link is shown
Scenario: Fresh install — registration is open
Given GET /v1/auth/registration resolves 200 with { "open": true }
When the user navigates to /login
Then the heading, description and fields render exactly as in the Default state
And the footer reads "First time here?" with a "Create the owner account" link
Scenario: Validation errors — nothing is sent for a short username and an empty password
Given the user is on /login
When the user types "ma" into Username, leaves Password empty, and submits
Then POST /v1/auth/login is never called
And the Username field shows the error "Use at least 3 characters."
And the Password field shows the error "Enter your password."
Scenario: Submitting — fields and button are disabled while the request is in flight
Given the user is on /login with Username "marta" and Password "limewash2026"
When the user submits and POST /v1/auth/login has not yet resolved
Then the Username and Password fields are disabled
And the submit button reads "Logging in…" and shows a loading state
Scenario: Wrong credentials — the password is cleared and focused
Given the user is on /login with Username "marta" and Password "wrong-password"
When the user submits and POST /v1/auth/login resolves 403
Then an error alert reads "That username and password don't match" with description
"Check both and try again. Passwords are case-sensitive."
And the Username field still shows "marta"
And the Password field is cleared and receives focus
And the submit button reads "Log in" again, no longer loading
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /login with Username "marta" and Password "limewash"
When the user submits and POST /v1/auth/login resolves 429 with a Retry-After of 42 seconds
Then an info alert reads "Too many attempts" with description
"For your security, wait a moment before trying again."
And the Username field still shows "marta" and the Password field still shows "limewash"
And the submit button reads "Try again in 0:42" and is disabled
When 42 seconds elapse
Then the submit button re-enables and reads "Log in"
Scenario: Session expired — redirected from a 401 elsewhere
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
When /login renders
Then an info alert reads "Your session expired" with description
"Log in again to pick up where you left off. Unsaved changes are kept on this device."
And the Password field receives focus
And the submit button reads "Log in"
Scenario: Password changed — arrived from a successful reset
Given the user is redirected to "/login?reason=password-changed"
When /login renders
Then a success alert reads "Password changed" with description "Log in with your new password."
And the Username field shows placeholder "Your username" with no value
And the submit button reads "Log in"
Scenario Outline: Server or network error — the fields keep their values
Given the user is on /login with Username "marta" and Password <password>
When the user submits and POST /v1/auth/login <failure>
Then an error alert reads "Couldn't reach RenewCMS" with description
"Check your connection and try again."
And the Username field still shows "marta" and the Password field still shows <password>
And the submit button reads "Log in" again, no longer loading
Examples:
| password | failure |
| "limewash2026" | fails with a 500 response |
| "limewash2026" | never reaches the server |
| "short1" | fails with a 400 response, because "short1" clears client-side validation (non-empty, 3+ characters) but is under the server's 8-character minimum, which the client does not check |
Scenario Outline: A successful log in navigates to the right destination
Given the user is on <path>
And GET /v1/auth/me, called after the 200 response, resolves 200 with is_verified <verified>
When the user submits valid credentials and POST /v1/auth/login resolves 200 with a jwt cookie
Then the app navigates to <destination>
Examples:
| path | verified | destination |
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
+95
View File
@@ -0,0 +1,95 @@
# QA: Log in screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`GET /v1/auth/registration`, `POST /v1/auth/login`, `GET /v1/auth/me`) per case; no
real backend, no httptest server. Mount LoginPage inside AuthShell with a test router whose current
route starts at the path each case needs.
1. **Default state** — mock `GET /v1/auth/registration` resolving `{ open: false }`; mount at
/login; assert heading "Log in", description "Welcome back. Log in to manage your articles.",
Username placeholder "Your username" with no value, Password with no value, submit button "Log
in", and no "Create the owner account" link.
2. **Fresh install state** — mock `GET /v1/auth/registration` resolving `{ open: true }`; mount at
/login; assert the same heading/description/fields as case 1, plus a "Create the owner account"
link under "First time here?".
3. **Validation errors state** — mount at /login; set Username to "ma", leave Password empty,
submit; assert the mocked `POST /v1/auth/login` was never called; assert Username shows "Use at
least 3 characters." and Password shows "Enter your password."
4. **Submitting state** — set Username "marta", Password "limewash2026"; mock
`POST /v1/auth/login` with an unresolved promise; submit; assert Username and Password are
disabled and the submit button reads "Logging in…" with a loading indicator.
5. **Wrong credentials state** — set Username "marta", Password "wrong-password"; mock
`POST /v1/auth/login` resolving 403; submit; assert an error alert with title "That username and
password don't match" and description "Check both and try again. Passwords are case-sensitive.";
assert Username still reads "marta"; assert Password is empty and has focus; assert the submit
button reads "Log in" and is no longer loading.
6. **Too many attempts state** — set Username "marta", Password "limewash"; mock
`POST /v1/auth/login` resolving 429 with a `Retry-After: 42` header; submit; assert an info alert
with title "Too many attempts" and description "For your security, wait a moment before trying
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
submit button reads "Try again in 0:42" and is disabled; advance fake timers by 42 seconds;
assert the button re-enables and reads "Log in".
7. **Session expired state** — mount at
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
this device."; assert the Password field has focus; assert the submit button reads "Log in".
8. **Password changed state** — mount at "/login?reason=password-changed"; assert a success alert
with title "Password changed" and description "Log in with your new password."; assert Username
shows placeholder "Your username" with no value.
9. **Server or network error state** — three sub-cases, one per Examples row: (a) a 500 response
with Password "limewash2026"; (b) a rejected fetch (network error) with Password "limewash2026";
(c) a 400 response with Password "short1" (6 characters — passes client validation since it's
non-empty and 3+ characters, but is under the server's 8-character minimum, which the client does
not check, so the request reaches the server and comes back 400). In each sub-case, set Username
"marta" and the row's Password, submit, and assert an error alert with title "Couldn't reach
RenewCMS" and description "Check your connection and try again."; assert Username still reads
"marta" and Password still reads the row's value; assert the submit button reads "Log in" and is
no longer loading. Sub-case (c) also asserts the mocked `POST /v1/auth/login` was actually called
(proving client validation let it through), unlike scenario 3's validation-errors case.
10. **Successful log in redirects correctly** — one test per Examples row: mock
`POST /v1/auth/login` resolving 200, then `GET /v1/auth/me` resolving 200 with the row's
`is_verified`; mount at the row's path; submit valid credentials; assert the router's current
path equals the row's destination.
| path | verified | destination |
|----------------------------------------|----------|-------------------------|
| /login | true | /home |
| /login | false | /register/pending |
| /login?redirect=/article/12/edit | true | /article/12/edit |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
- [ ] Default state, desktop and mobile, light and dark: heading, description, Username, Password
with show/hide toggle, "Forgot password?" link, "Log in" button, matches the board exactly.
- [ ] Fresh install state: "First time here? Create the owner account" footer renders below the
form, desktop and mobile, light and dark.
- [ ] Validation errors state: both field errors render inline under their fields with the error
styling from components.md, desktop and mobile, light and dark.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button, desktop
and mobile, light and dark.
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
cleared/focused Password field, desktop and mobile, light and dark.
- [ ] Too many attempts state: info alert (timer icon), disabled button with the live "Try again in
m:ss" countdown text, desktop and mobile, light and dark.
- [ ] Session expired state: info alert (clock icon), focused Password field, desktop and mobile,
light and dark.
- [ ] Password changed state: success alert (circle-check icon, dismissible close button per
components.md), desktop and mobile, light and dark.
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
their values, desktop and mobile, light and dark.
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
submit; the alert (when present) is announced via `role="status"` (info/success) or
`role="alert"` (error) without re-announcing on re-render; Password receives programmatic focus
exactly in the Wrong credentials and Session expired states, and nowhere else.
## Manual verification
- **Real GET /v1/auth/registration and POST /v1/auth/login against a running server** — Vitest only
mocks `useApi`; confirm the Default/Fresh install split and a real 403/429/200 round-trip against
`task dev` with a live SQLite file, since this slice's automated tests never exercise the actual Go
handlers, the rate limiter, or the real `jwt` cookie.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift or double-fire
the re-enable against a real elapsed 42-second wait, since Vitest fake timers only prove the logic,
not real wall-clock behavior.
+40
View File
@@ -0,0 +1,40 @@
import { computed, onUnmounted, ref } from 'vue'
export function useRetryAfter() {
const remaining = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function clear() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
function start(seconds: number) {
clear()
remaining.value = Math.max(0, Math.floor(seconds))
if (remaining.value <= 0) {
return
}
timer = setInterval(() => {
remaining.value -= 1
if (remaining.value <= 0) {
remaining.value = 0
clear()
}
}, 1000)
}
const active = computed(() => remaining.value > 0)
const formatted = computed(() => {
const minutes = Math.floor(remaining.value / 60)
const seconds = remaining.value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
onUnmounted(clear)
return { remaining, active, formatted, start }
}
+264 -2
View File
@@ -1,9 +1,271 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSessionStore } from '@/stores/session'
interface AlertState {
tone: 'error' | 'info' | 'success'
icon: string
title: string
description: string
closable: boolean
}
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.',
closable: false,
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many attempts',
description: 'For your security, wait a moment before trying again.',
closable: false,
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't reach RenewCMS",
description: 'Check your connection and try again.',
closable: false,
}
const sessionExpiredAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-clock',
title: 'Your session expired',
description:
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
closable: true,
}
const passwordChangedAlert: AlertState = {
tone: 'success',
icon: 'i-lucide-circle-check',
title: 'Password changed',
description: 'Log in with your new password.',
closable: true,
}
const route = useRoute()
const router = useRouter()
const api = useApi()
const session = useSessionStore()
const retryAfter = useRetryAfter()
const registrationOpen = ref(false)
const username = ref('')
const password = ref('')
const usernameError = ref('')
const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const formEl = ref<HTMLFormElement | null>(null)
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
const alert = ref<AlertState | null>(
initialReason === 'expired'
? sessionExpiredAlert
: initialReason === 'password-changed'
? passwordChangedAlert
: null,
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const submitLabel = computed(() => {
if (submitting.value) {
return 'Logging in…'
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
}
return 'Log in'
})
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
function focusField(selector: string) {
nextTick(() => {
formEl.value?.querySelector<HTMLInputElement>(selector)?.focus()
})
}
function focusPassword() {
focusField('input[autocomplete="current-password"]')
}
function focusUsername() {
focusField('input[placeholder="Your username"]')
}
function focusFirstInvalid() {
if (usernameError.value) {
focusUsername()
return
}
if (passwordError.value) {
focusPassword()
}
}
function validate() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function afterLoginSuccess() {
await session.retry()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
if (redirect) {
await router.push(redirect)
return
}
if (session.session?.verified) {
await router.push('/home')
return
}
await router.push('/register/pending')
}
function handleLoginError(error: unknown) {
if (error instanceof ApiError && error.status === 403) {
alert.value = wrongCredentialsAlert
password.value = ''
focusPassword()
return
}
if (error instanceof ApiError && error.status === 429) {
alert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
alert.value = serverErrorAlert
}
async function onSubmit() {
if (submitDisabled.value) {
return
}
if (!validate()) {
focusFirstInvalid()
return
}
submitting.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 }),
})
await afterLoginSuccess()
} catch (error) {
handleLoginError(error)
} finally {
submitting.value = false
}
}
onMounted(async () => {
if (initialReason === 'expired') {
focusPassword()
}
try {
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
registrationOpen.value = status.open
} catch {
registrationOpen.value = false
}
})
</script>
<template>
<AuthShell title="Log in">
<p>Log in placeholder.</p>
<AuthShell title="Log in" description="Welcome back. Log in to manage your articles.">
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
:icon="alert.icon"
:title="alert.title"
:description="alert.description"
variant="soft"
:close="alert.closable"
:role="alertRole"
@close="alert = null"
/>
</template>
<form ref="formEl" class="flex flex-col gap-4" @submit.prevent="onSubmit">
<UFormField label="Username" name="username" :error="usernameError">
<UInput
v-model="username"
placeholder="Your username"
autocomplete="username"
:disabled="submitting"
class="w-full"
/>
</UFormField>
<UFormField name="password" :error="passwordError">
<template #label>
<div class="flex w-full items-center justify-between">
<span>Password</span>
<RouterLink to="/register/reset/request" class="text-primary text-sm">
Forgot password?
</RouterLink>
</div>
</template>
<UInput
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="current-password"
:disabled="submitting"
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 :loading="submitting" :disabled="submitDisabled">
{{ submitLabel }}
</UButton>
</form>
<template v-if="registrationOpen" #footer>
<p>
First time here?
<RouterLink to="/register" class="text-primary"
>Create the owner account</RouterLink
>
</p>
</template>
</AuthShell>
</template>
+306
View File
@@ -0,0 +1,306 @@
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 LoginView from '../LoginView.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 currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(
path: string,
handlers: Record<string, () => Response | Promise<Response>>,
) {
setActivePinia(createPinia())
global.fetch = fetchRouter({
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
...handlers,
}) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
async function fillAndSubmit(
wrapper: Awaited<ReturnType<typeof mountLogin>>,
username: string,
password: string,
) {
await wrapper.find('input[placeholder="Your username"]').setValue(username)
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — registration is closed', () => {
it('renders the default state with no create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect(wrapper.text()).not.toContain('Create the owner account')
})
})
describe('Fresh install — registration is open', () => {
it('renders the same fields plus the create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain('First time here?')
expect(wrapper.text()).toContain('Create the owner account')
})
})
describe('Validation errors — nothing is sent for a short username and an empty password', () => {
it('shows both field errors and never calls the login endpoint', async () => {
const login = vi.fn(() => mockResponse(200))
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': login,
})
await wrapper.find('input[placeholder="Your username"]').setValue('ma')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(login).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Use at least 3 characters.')
expect(wrapper.text()).toContain('Enter your password.')
})
})
describe('Submitting — fields and button are disabled while the request is in flight', () => {
it('disables the fields and shows the loading label', async () => {
let resolveLogin: (() => void) | null = null
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () =>
new Promise((resolve) => {
resolveLogin = () => resolve(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('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(true)
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Logging in…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveLogin?.()
await flushPromises()
})
})
describe('Wrong credentials — the password is cleared and focused', () => {
it('shows the mismatch alert, keeps the username, clears and focuses the password', async () => {
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () => mockResponse(403),
})
await fillAndSubmit(wrapper, 'marta', 'wrong-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 usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Too many attempts — the button is disabled with a live countdown', () => {
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash')
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying 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('limewash')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:42')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
await vi.advanceTimersByTimeAsync(42000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
vi.useRealTimers()
})
})
describe('Session expired — redirected from a 401 elsewhere', () => {
it('shows the session-expired alert and focuses the password field', async () => {
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
expect(wrapper.text()).toContain('Your session expired')
expect(wrapper.text()).toContain(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(passwordInput.element).toBe(document.activeElement)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
})
describe('Password changed — arrived from a successful reset', () => {
it('shows the success alert and the untouched fields', async () => {
const wrapper = await mountLogin('/login?reason=password-changed', {})
expect(wrapper.text()).toContain('Password changed')
expect(wrapper.text()).toContain('Log in with your new password.')
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
})
describe('Server or network error — the fields keep their values', () => {
it.each([
{ name: 'a 500 response', password: 'limewash2026', handler: () => mockResponse(500) },
{
name: 'a network failure',
password: 'limewash2026',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
{
name: 'a 400 response under the server minimum',
password: 'short1',
handler: () => mockResponse(400),
},
])(
'shows the network/server error alert and keeps the fields for $name',
async ({ password, handler }) => {
const login = vi.fn(handler)
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': login,
})
await fillAndSubmit(wrapper, 'marta', password)
expect(login).toHaveBeenCalled()
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(password)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
},
)
})
describe('A successful log in navigates to the right destination', () => {
it.each([
{ path: '/login', verified: true, destination: '/home' },
{ path: '/login', verified: false, destination: '/register/pending' },
{
path: '/login?redirect=/article/12/edit',
verified: true,
destination: '/article/12/edit',
},
])(
'navigates to $destination for path $path and verified $verified',
async ({ path, verified, destination }) => {
const wrapper = await mountLogin(path, {
'POST /v1/auth/login': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
},
)
})