diff --git a/features/login_screen.feature b/features/login_screen.feature new file mode 100644 index 0000000..6326467 --- /dev/null +++ b/features/login_screen.feature @@ -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 + When the user submits and POST /v1/auth/login + 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 + 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 + And GET /v1/auth/me, called after the 200 response, resolves 200 with is_verified + When the user submits valid credentials and POST /v1/auth/login resolves 200 with a jwt cookie + Then the app navigates to + + Examples: + | path | verified | destination | + | /login | true | /home | + | /login | false | /register/pending | + | /login?redirect=/article/12/edit | true | /article/12/edit | diff --git a/features/login_screen.qa.md b/features/login_screen.qa.md new file mode 100644 index 0000000..d06836a --- /dev/null +++ b/features/login_screen.qa.md @@ -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. diff --git a/web/src/composables/useRetryAfter.ts b/web/src/composables/useRetryAfter.ts new file mode 100644 index 0000000..68dba9d --- /dev/null +++ b/web/src/composables/useRetryAfter.ts @@ -0,0 +1,40 @@ +import { computed, onUnmounted, ref } from 'vue' + +export function useRetryAfter() { + const remaining = ref(0) + let timer: ReturnType | 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 } +} diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index f7d7dbb..8348a29 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,9 +1,271 @@ diff --git a/web/src/views/__tests__/LoginView.spec.ts b/web/src/views/__tests__/LoginView.spec.ts new file mode 100644 index 0000000..fb2a218 --- /dev/null +++ b/web/src/views/__tests__/LoginView.spec.ts @@ -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 = {}) { + 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 Response | Promise>) { + 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> | null = null + +async function mountLogin( + path: string, + handlers: Record Response | Promise>, +) { + 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>, + 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)) + }, + ) +})