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, the title "Verifying your account…" and the line "This only takes a moment.", as the design board's AuthCard spinner draws it, 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 a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified", description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board draws it, 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 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 button is enabled, as the design board draws it; on submit, LoginView's own validation runs first (Username under 3 characters shows "Use at least 3 characters.", an empty Password shows "Enter your password.", and nothing is sent), and a valid form 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 a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your account is verified", description "Welcome to RenewCMS." And session.retry() is awaited And after 800 ms and once session.retry() has resolved, the app navigates to /home 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 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 — empty fields are caught on submit, as on Log in Given the inline log-in form from "No session" is shown Then the submit button is enabled When the user submits with both fields empty Then Username shows "Use at least 3 characters." and Password shows "Enter your password." And POST /v1/auth/login is not called 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 it and both fields are disabled until the countdown ends, mirroring LoginView 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 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