Feature: Password reset confirm screen As a user who opened the reset link from their email I want /register/reset/validate?c=CODE&email=EMAIL to let me choose a new password via POST /v1/auth/password-reset/confirm, checking length and match in the browser first, and to tell me plainly when the link is incomplete, the code is wrong or expired, or the server failed So that finishing a password reset is a single form from the email, with the same neutral "invalid or expired" message whether the code was wrong, reused, or the account doesn't exist Background: Given /register/reset/validate is a public route (meta.auth "public") rendered as ResetConfirmPage inside AuthShell, per docs/design/routes.md And ResetConfirmPage only calls POST /v1/auth/password-reset/confirm And on mount, ResetConfirmPage reads the "c" and "email" query parameters once: if either is absent or an empty string, no request is ever made and the Missing parameters state renders immediately instead of the form And when both are present, the form has a read-only Email field (value from the "email" query parameter, help text "From your reset link."), a New password field (password input with show/hide, a live checklist item "At least 8 characters" that starts unmet/gray, turns error/red only after a failed submit, and turns met/green once satisfied), and a Confirm new password field (password input with show/hide, a live checklist item "Passwords match" with the same unmet/error/met states) And New password's length is checked on submit only: fewer than 8 characters leaves its checklist item in its error state and blocks the request And Confirm new password's match against New password is checked on blur of that field and again on submit: a mismatch shows the error "The passwords don't match." and leaves the "Passwords match" checklist item in its error state, and blocks the request And a submit that passes both checks calls POST /v1/auth/password-reset/confirm with { email, code: c, password: }; the Confirm new password value is never sent And on a 200 response, the app navigates to "/login?reason=password-changed", whose rendering is covered by features/login_screen.feature's "Password changed" scenario, not re-specified here And a 403 response (per features/reset_password.feature, covering an unknown email, a wrong code, an already-used code, or an expired code alike) replaces the whole form with a ResultPanel And a 500 response, or a request that never reaches the server, is surfaced with the app's generic network/server-error alert — icon "i-lucide-circle-x", title "Couldn't reach RenewCMS", description "Check your connection and try again.", the same copy LoginPage and RegisterPage already use for the same failure; the form is not replaced, both password fields keep whatever the user typed, and the form re-enables, since docs/design/screens/reset-password.md has no state of its own for this response and this is the app's generic pattern for it (per the user's decision on this slice) Scenario: Default — both parameters present, an untouched form When the user navigates to "/register/reset/validate?c=abc123&email=marta%40renew.example" Then the heading is "Choose a new password" And the description is "Pick something you haven't used here before. You'll log in with it next." And the Email field shows "marta@renew.example", read-only, with help "From your reset link." And the New password field shows no value, with checklist item "At least 8 characters" in its unmet state And the Confirm new password field shows no value, with checklist item "Passwords match" in its unmet state And the submit button reads "Change password" Scenario: Too short — fewer than 8 characters blocks submission Given the user is on the reset-confirm form with New password "lime" and Confirm new password "lime" When the user submits Then POST /v1/auth/password-reset/confirm is never called And the New password checklist item "At least 8 characters" shows its error state And the Confirm new password checklist item "Passwords match" shows its met state, since both fields hold the same value Scenario: Mismatch — caught on blur and on submit Given the user is on the reset-confirm form with New password "limewash2026" When the user types "limewash2025" into Confirm new password and blurs the field Then the Confirm new password field shows the error "The passwords don't match." and its checklist item "Passwords match" shows its error state When the user submits with these same values Then POST /v1/auth/password-reset/confirm is never called And the same mismatch error is still shown Scenario: Submitting — fields and button are disabled while the request is in flight Given the user is on the reset-confirm form with New password "limewash2026" and Confirm new password "limewash2026" When the user submits and POST /v1/auth/password-reset/confirm has not yet resolved Then the New password and Confirm new password fields are disabled And the submit button reads "Changing password…" and shows a loading state Scenario: Success — a 200 hands off to the log-in screen Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with New password "limewash2026" and Confirm new password "limewash2026" When the user submits and POST /v1/auth/password-reset/confirm resolves 200 Then the app navigates to "/login?reason=password-changed" Scenario: Invalid or expired — a 403 replaces the form with a ResultPanel Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with New password "limewash2026" and Confirm new password "limewash2026" When the user submits and POST /v1/auth/password-reset/confirm resolves 403 with the apperr.PasswordResetFailedErrorMessage body Then the form is no longer shown And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This reset link has expired", text "Reset links work once, for 1 hour. Request a new one and use it within the hour." And its actions are a primary "Request a new link" and a secondary "Back to log in" When the user activates "Request a new link" Then the app navigates to /register/reset/request When instead the user activates "Back to log in" Then the app navigates to /login Scenario Outline: Server or network error — the form keeps its values and re-enables Given the user is on "/register/reset/validate?c=abc123&email=marta%40renew.example" with New password "limewash2026" and Confirm new password "limewash2026" When the user submits and POST /v1/auth/password-reset/confirm Then an error alert reads "Couldn't reach RenewCMS" with description "Check your connection and try again." And the form is still shown, not replaced by a ResultPanel And the New password field still shows "limewash2026" and the Confirm new password field still shows "limewash2026", both re-enabled And the submit button reads "Change password" again, no longer loading Examples: | failure | | fails with a 500 response | | never reaches the server | Scenario Outline: Missing parameters — either missing parameter blocks the request When the user navigates to 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 "Part of the reset link is missing. Open it from your email again, or request a new one." And its single action is a primary "Request a new link" When the user activates "Request a new link" Then the app navigates to /register/reset/request Examples: | path | | "/register/reset/validate?email=marta%40renew.example" | | "/register/reset/validate?c=abc123" | | "/register/reset/validate" |