Files
RenewCMS/features/verify_screen.feature
T

198 lines
13 KiB
Gherkin

Feature: Verify account screen
As a user who just clicked the verification link in their email
I want /register/validate?c=CODE to submit that code automatically, land me on my dashboard once
it's accepted, tell me plainly when it's wrong or expired, and let me log in inline if I opened
the link somewhere I'm not logged in
So that verifying my account is a single click from my inbox, whatever device or session state I
open the link in
Background:
Given /register/validate is a "public" route (meta.auth "public"), rendered as VerifyPage
inside AuthShell, per docs/design/routes.md; the router's beforeEach never runs session.check
for a public route, so VerifyPage starts with no assumption about the session
And VerifyPage only calls POST /v1/auth/verify, DELETE /v1/auth/register, and POST
/v1/auth/logout (and, inline, POST /v1/auth/login on a 401)
And on mount, VerifyPage reads the "c" query parameter once: if it is absent or an empty
string, no request is ever made and the Missing code state renders immediately; otherwise the
Verifying state renders and POST /v1/auth/verify is called immediately with { code: c }
And every call to POST /v1/auth/verify (the initial one and the post-login retry) passes
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' } (the same onForbidden
RequestOptions field added to useApi by features/pending_screen.feature, plus the existing
onUnauthorized field used the same way getMe() already uses it), so a 401 renders the inline
log-in state instead of the global redirect to /login, and a 403 renders the Invalid-or-
expired state instead of the global "unverified session" redirect
And Verifying and No-session-retrying both render the same full-card spinner: a centered
"i-lucide-loader-2" icon with a spin animation and the text "Verifying your account…",
role="status", replacing AuthShell's whole card body with no title header shown
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
concurrently awaiting session.retry() (needed so /home's route guard sees the now-verified
session instead of whatever stale state this tab last cached) and an 800 ms timer; once both
settle, the app navigates to /home and a toast (color "success", icon "i-lucide-circle-check",
title "Your account is verified", description "Welcome to RenewCMS.") is shown
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
and a note (rendered via the default slot) "Cancel your registration and start over to get a
new link.", with a primary action "Cancel registration and start over" and a secondary action
"Log out"; unlike Pending's cancel flow, this button opens no ConfirmDialog (verify.md's state
list has no confirm step for it) and calls DELETE /v1/auth/register directly, passing
{ onForbidden: 'passthrough' } for the same reason Pending's own DELETE call does
And "Cancel registration and start over" resolving 200 navigates to /register and shows the
same toast Pending's own Cancelled state shows (color "success", icon "i-lucide-circle-check",
title "Registration cancelled", description "You can create the owner account again."),
reusing that copy since it is the same underlying event
And "Cancel registration and start over" failing — with a 403 (already verified elsewhere, so
there is nothing left to cancel), a 500, or a request that never reaches the server — shows an
alert on the same ResultPanel screen via AuthShell's #notice slot, reusing Register's existing
server-error pattern: color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment.",
variant "soft", not closable, role "alert"; the ResultPanel and its two actions stay exactly
as they were so the user can try again or choose "Log out" instead; no redirect happens for
the 403 case here, unlike Pending's own already-verified handling
And "Log out" (on the Invalid-or-expired state) behaves exactly as it does on Pending: it calls
POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and clears the jwt
cookie, then VerifyPage calls session.clear() and navigates to /login, disabling both actions
while the call is in flight
And on a 401, VerifyPage replaces the spinner with an inline log-in form: AuthShell title "Log
in to finish verifying", description "You opened this link somewhere you're not logged in.
Log in here and we'll verify your account right after.", a Username field (placeholder "Your
username") and a Password field (password input with a show/hide toggle), and a submit button
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
unlike LoginView
And the inline form's submit is disabled until both fields are non-empty; on submit it calls
POST /v1/auth/login with { username, password }
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
follows the 200/403 handling above
And a 403 (wrong credentials), 429, or 500/network failure from that inline login call reuses
LoginView's existing alert copy for the same statuses (wrongCredentialsAlert, with LoginView's
exact title/description; tooManyAttemptsAlert with the same retryAfter countdown behavior as
LoginView; serverErrorAlert), shown via AuthShell's #notice slot, since verify.md specifies no
separate copy for this embedded form and states.md's HTTP-status table assigns this copy
generically to "Log in" contexts
And while that inline login is submitting, both fields are disabled and the submit button reads
"Logging in…" with a loading indicator, mirroring LoginView's submitting state
Scenario: Verifying — the code submits automatically on load
When the user navigates to "/register/validate?c=abc123"
Then the full-card spinner is shown with the text "Verifying your account…"
And POST /v1/auth/verify is called once, immediately, with body {"code": "abc123"}
Scenario: Success — 200 refreshes the session and navigates to /home with a toast
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 200
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
account is verified", text "Taking you to your dashboard…", and no actions
And session.retry() is awaited
And after 800 ms and once session.retry() has resolved, the app navigates to /home
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Your account is
verified", description "Welcome to RenewCMS."
Scenario: Invalid or expired — 403 offers a way to start over
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 403
Then a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This
verification link doesn't work", text "It may have expired (links last 2 hours) or already
been used.", and note "Cancel your registration and start over to get a new link."
And its actions are a primary "Cancel registration and start over" and a secondary "Log out"
Scenario: Cancel registration and start over — success
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
Then DELETE /v1/auth/register is sent directly, without any confirmation dialog, with
{ onForbidden: 'passthrough' }
When it resolves 200
Then the app navigates to /register
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
cancelled", description "You can create the owner account again."
Scenario Outline: Cancel registration and start over — failure shows an alert, not a redirect
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Cancel registration and start over"
And DELETE /v1/auth/register <failure>
Then an alert is shown with color "error", icon "i-lucide-circle-x", title "Couldn't cancel
registration", description "Something went wrong on the server. Try again in a moment."
And the ResultPanel and its two actions ("Cancel registration and start over", "Log out") are
still shown, unchanged, so the user can retry or log out instead
And no navigation happens
Examples:
| failure |
| resolves 403 with the apperr.AlreadyVerifiedErrorMessage body |
| fails with a 500 response |
| never reaches the server |
Scenario: Log out from the Invalid-or-expired state
Given the ResultPanel from "Invalid or expired" is shown
When the user activates "Log out"
Then POST /v1/auth/logout is sent
When it resolves 200
Then the session store is cleared
And the app navigates to /login
Scenario: No session — the inline log-in form replaces the spinner
Given the user is on "/register/validate?c=abc123" in the Verifying state
When POST /v1/auth/verify resolves 401
Then the spinner is replaced by a form titled "Log in to finish verifying" with description
"You opened this link somewhere you're not logged in. Log in here and we'll verify your
account right after."
And it has a Username field (placeholder "Your username") and a Password field with a show/hide
toggle, and a submit button "Log in and verify"
And there is no "Forgot password?" link and no registration-open footer
Scenario: No session — submit is disabled until both fields are filled
Given the inline log-in form from "No session" is shown
Then the submit button is disabled
When the user types "marta" into Username and "limewash2026" into Password
Then the submit button is enabled
Scenario: No session — successful login retries verification with the same code
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 200
Then the full-card spinner is shown again with the text "Verifying your account…"
And POST /v1/auth/verify is sent again, with body {"code": "abc123"} and the same passthrough
options as the first attempt
Scenario: No session — wrong credentials during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "wrong-password"
And POST /v1/auth/login resolves 403
Then an error alert is shown with title "That username and password don't match" and
description "Check both and try again. Passwords are case-sensitive."
And the Password field is cleared and refocused
And POST /v1/auth/verify is not retried
Scenario: No session — too many login attempts
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login resolves 429 with Retry-After 30
Then an info alert is shown with title "Too many attempts" and description "For your security,
wait a moment before trying again."
And the submit button reads "Try again in 0:30" and is disabled until the countdown ends
Scenario Outline: No session — server or network error during the inline login
Given the inline log-in form from "No session" is shown
When the user submits Username "marta" and Password "limewash2026"
And POST /v1/auth/login <failure>
Then an error alert is shown with title "Couldn't reach RenewCMS" and description "Check your
connection and try again."
And the fields keep their values and are re-enabled
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
Scenario: Missing code — no request is made
When the user navigates to "/register/validate" with no "c" query parameter
Then no request is made
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link is
incomplete", text "The verification code is missing. Open the link from your email again, or
copy the full address into your browser."
And its single action is a secondary "Go to log in"
When the user activates "Go to log in"
Then the app navigates to /login