From 3c3f7c619441f80bebbad5bff008b28a7dc1355e Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sat, 26 Sep 2026 02:48:16 +0200 Subject: [PATCH] feat: register T0 --- features/register_screen.feature | 112 +++++++ features/register_screen.qa.md | 73 +++++ web/src/components/AuthShell.vue | 10 +- web/src/components/PasswordStrengthHint.vue | 49 +++ web/src/components/ResultPanel.vue | 65 ++++ .../components/__tests__/AuthShell.spec.ts | 22 ++ web/src/views/RegisterView.vue | 241 +++++++++++++- web/src/views/__tests__/RegisterView.spec.ts | 299 ++++++++++++++++++ web/visual/states.ts | 53 ++++ 9 files changed, 920 insertions(+), 4 deletions(-) create mode 100644 features/register_screen.feature create mode 100644 features/register_screen.qa.md create mode 100644 web/src/components/PasswordStrengthHint.vue create mode 100644 web/src/components/ResultPanel.vue create mode 100644 web/src/views/__tests__/RegisterView.spec.ts diff --git a/features/register_screen.feature b/features/register_screen.feature new file mode 100644 index 0000000..ba13514 --- /dev/null +++ b/features/register_screen.feature @@ -0,0 +1,112 @@ +Feature: Register screen + + As the first person to set up a fresh RenewCMS install + I want a /register screen that creates the single owner account via POST /v1/auth/register, + validates the same rules the API enforces before submitting, and tells me plainly when + someone already owns this site or the server failed + So that I can create my account once, land on the verification-pending page, and never see a + confusing or silent failure + + Background: + Given /register is a public route (meta.auth "public") rendered as RegisterPage inside + AuthShell, per docs/design/routes.md + And RegisterPage only calls POST /v1/auth/register; it never calls GET /v1/auth/registration + And the form has a Username field (placeholder "e.g. marta", a live "N / 20" character + counter, help text "Letters and numbers, 3 to 20 characters. You'll use it to log in."), an + Email field (placeholder "you@example.com", help text "We'll send a verification link + here."), and a Password field (a password input with a show/hide toggle and a live + checklist item "At least 8 characters" that is unmet/error until the rule is satisfied, then + turns met/green) + And client-side validation runs on blur of each field and again on submit, before any request + is sent, using the same rules as the API: Username must be 3 to 20 characters (the API has + no character-class constraint beyond length, so the client checks length only, matching the + help text's length claim but not attempting to enforce "letters and numbers" client-side), + Email must be a well-formed email address, and Password must be at least 8 characters + And a failing Username shows "Use 3 to 20 characters."; a failing Email shows "Enter a full + email address, like name@example.com."; a failing Password leaves the checklist item in its + unmet/error state + And a valid submit calls POST /v1/auth/register with { username, email, password } + And on a 200 response, the jwt cookie is set by the server and the app navigates to + /register/pending without re-resolving the session first + And a 403 response means someone already verified an owner account (user.ErrRegistrationClosed + on the backend) and the whole form is replaced by a ResultPanel + And a 500 response, or a request that never reaches the server, is surfaced as a server/network + error; the fields keep whatever the user typed and the form re-enables + And a 400 response is not expected once client-side validation passes, since the client checks + the same rules as the API; it is out of scope for this slice + + Scenario: Default — a fresh, empty form + When the user navigates to /register + Then the heading is "Create the owner account" + And the description is "This creates the single admin account for this site. Once it's + verified, registration closes." + And the Username field shows placeholder "e.g. marta" with no value and counter "0 / 20" + And the Email field shows placeholder "you@example.com" with no value + And the Password field shows no value, with a show/hide toggle and the checklist item + "At least 8 characters" in its unmet state + And the submit button reads "Create account" + And the footer reads "Already set up?" with a "Log in" link to /login + + Scenario: Validation errors — checked on blur and on submit + Given the user is on /register + When the user types "ma" into Username and blurs the field + Then the Username counter reads "2 / 20" in its error state and the error "Use 3 to 20 + characters." is shown + When the user types "marta@renew" into Email and blurs the field + Then the Email field shows the error "Enter a full email address, like name@example.com." + When the user types "lime" into Password and blurs the field + Then the checklist item "At least 8 characters" stays in its error state + When the user submits with these same values + Then POST /v1/auth/register is never called + And all three errors from above are still shown + + Scenario: Filled, valid — every rule is met + Given the user is on /register + When the user types "marta" into Username, "marta@renew.example" into Email, and + "limewash2026" into Password, and reveals the password with the show/hide toggle + Then the Username counter reads "5 / 20" with no error, and the help text "Letters and + numbers, 3 to 20 characters. You'll use it to log in." is shown + And the Email field shows no error, with the help text "We'll send a verification link here." + And the Password field shows "limewash2026" as plain text and the checklist item "At least 8 + characters" turns to its met/green state + And the submit button reads "Create account" and is enabled + + Scenario: Submitting — fields and button are disabled while the request is in flight + Given the user is on /register with Username "marta", Email "marta@renew.example", and + Password "limewash2026" + When the user submits and POST /v1/auth/register has not yet resolved + Then the Username, Email and Password fields are disabled + And the submit button reads "Creating account…" and shows a loading state + + Scenario: Registration closed — the form is replaced by a ResultPanel + Given the user is on /register with Username "marta", Email "marta@renew.example", and + Password "limewash2026" + When the user submits and POST /v1/auth/register resolves 403 + Then the form, its fields and the footer are no longer shown + And a ResultPanel with tone "info" and icon "user-x" is shown instead, with title "This site + already has an owner" and text "Log in instead." + And its single action is a primary button "Go to log in" + When the user activates "Go to log in" + Then the app navigates to /login + + Scenario Outline: Server or network error — the form is kept and re-enabled + Given the user is on /register with Username "marta", Email "marta@renew.example", and + Password "limewash2026" + When the user submits and POST /v1/auth/register + Then an error alert reads "Couldn't create the account" with description "Something went + wrong on the server. Try again in a moment." + And the Username field still shows "marta", the Email field still shows "marta@renew.example", + and the Password field still shows "limewash2026" + And the Username, Email and Password fields are re-enabled + And the submit button reads "Create account" again, no longer loading + + Examples: + | failure | + | fails with a 500 response | + | never reaches the server | + + Scenario: A successful registration goes straight to the pending page + Given the user is on /register with Username "marta", Email "marta@renew.example", and + Password "limewash2026" + When the user submits and POST /v1/auth/register resolves 200 with a jwt cookie + Then the app navigates to /register/pending diff --git a/features/register_screen.qa.md b/features/register_screen.qa.md new file mode 100644 index 0000000..51cae42 --- /dev/null +++ b/features/register_screen.qa.md @@ -0,0 +1,73 @@ +# QA: Register screen + +## Automated (Vitest + Vue Test Utils, `task test:web`) + +Mock `useApi` (`POST /v1/auth/register` only — RegisterPage never calls +`GET /v1/auth/registration`); no real backend, no httptest server. Mount RegisterPage inside +AuthShell with a test router. + +1. **Default state** — mount at /register; assert heading "Create the owner account", description + "This creates the single admin account for this site. Once it's verified, registration + closes.", Username placeholder "e.g. marta" with no value and counter "0 / 20", Email + placeholder "you@example.com" with no value, Password with no value and the checklist item + "At least 8 characters" in its unmet state, submit button "Create account", and the footer + "Already set up? Log in" linking to /login. +2. **Validation errors state** — mount at /register; set Username to "ma" and blur; assert counter + "2 / 20" in error and the error "Use 3 to 20 characters."; set Email to "marta@renew" and blur; + assert the error "Enter a full email address, like name@example.com."; set Password to "lime" + and blur; assert the checklist item stays in its error state; submit; assert the mocked + `POST /v1/auth/register` was never called and all three errors are still shown. +3. **Filled, valid state** — set Username "marta", Email "marta@renew.example", Password + "limewash2026"; toggle password visibility on; assert counter "5 / 20" with no error and the + help text restored, no Email error, Password rendered as plain text, the checklist item in its + met/green state, and the submit button enabled and reading "Create account". +4. **Submitting state** — set Username "marta", Email "marta@renew.example", Password + "limewash2026"; mock `POST /v1/auth/register` with an unresolved promise; submit; assert + Username, Email and Password are disabled and the submit button reads "Creating account…" with + a loading indicator. +5. **Registration closed state** — set the same valid values; mock `POST /v1/auth/register` + resolving 403; submit; assert the form and footer are no longer rendered; assert a ResultPanel + with title "This site already has an owner", text "Log in instead.", and a single primary + action "Go to log in"; click it and assert the router's current path is /login. +6. **Server or network error state** — two sub-cases: (a) a 500 response, (b) a rejected fetch + (network error). In each, set Username "marta", Email "marta@renew.example", Password + "limewash2026", submit, and assert an error alert with title "Couldn't create the account" and + description "Something went wrong on the server. Try again in a moment."; assert Username, + Email and Password still show their values; assert the three fields are re-enabled; assert the + submit button reads "Create account" and is no longer loading. +7. **Successful registration navigates to pending** — mock `POST /v1/auth/register` resolving 200; + set the same valid values; submit; assert the router's current path is /register/pending, and + that no `GET /v1/auth/me` call was made by RegisterPage itself. + +## Visual (manual verification — desktop and mobile, light and dark) + +Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Register". Run +`task visual:check -- register` for each state and compare against the board frame. + +- [ ] Default state: heading, description, Username with counter and help text, Email with help + text, Password with show/hide toggle and the unmet checklist item, "Create account" button, + "Already set up? Log in" footer — matches the board exactly. +- [ ] Validation errors state: Username counter and error, Email error, and the Password checklist + item's error styling all render inline per components.md. +- [ ] Filled, valid state: the checklist item's met/green transition and the revealed password + text render correctly. +- [ ] Submitting state: disabled field styling and the loading spinner in the submit button. +- [ ] Registration closed state: the ResultPanel (info tone, user-x icon tile, title, text, single + primary "Go to log in" action) fully replaces the form and footer. +- [ ] Server error state: error alert (circle-x icon, no close button per the Log in screen's + precedent) above the heading, fields retaining their values, form re-enabled. +- [ ] Keyboard/focus: Tab order is Username → Email → Password → show/hide toggle → submit; when + the Registration closed ResultPanel appears, focus moves to its title and it is + `role="status"`; the server error alert is `role="alert"` and does not steal focus from + wherever the user was; the checklist item's live state change is not visually jarring + (no layout shift) as it flips from unmet to met. + +## Manual verification + +- **Real POST /v1/auth/register against a running server** — Vitest only mocks `useApi`; confirm + the real 403 (`user.ErrRegistrationClosed`) and a real 200 round-trip with the actual jwt cookie + against `task dev` with a live SQLite file, since this slice's automated tests never exercise the + actual Go handler, the `CreateUser` use case, or cookie issuance. +- **Real navigation to /register/pending after a live registration** — confirm the route guard + reads the freshly set cookie correctly, since Vitest never resolves a real session against + GET /v1/auth/me on that navigation. diff --git a/web/src/components/AuthShell.vue b/web/src/components/AuthShell.vue index d715d3d..2e5b942 100644 --- a/web/src/components/AuthShell.vue +++ b/web/src/components/AuthShell.vue @@ -60,6 +60,9 @@ const colorModeSelectUi = { trailing: 'hidden' } watch( () => props.title, (title) => { + if (!title) { + return + } document.title = `${title} · RenewCMS` }, { immediate: true }, @@ -67,7 +70,10 @@ watch( watch( () => props.title, - () => { + (title) => { + if (!title) { + return + } titleEl.value?.focus() }, ) @@ -86,7 +92,7 @@ watch( -