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.