feat: password reset full design and flow

This commit is contained in:
fsylvain committed 2026-09-28 05:06:16 +02:00
1 parent 938263cd6a
commit cb05b38f36
9 files changed
+1471 -4

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
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: <New 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 <failure>
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 <path>
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" |
+83
View File
@@ -0,0 +1,83 @@
# QA: Password reset confirm screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset/confirm`) per case; no real backend, no httptest
server. Mount ResetConfirmPage inside AuthShell with a test router whose current route starts at
the query string each case needs.
1. **Default state** — mount at
"/register/reset/validate?c=abc123&email=marta%40renew.example"; assert heading "Choose a new
password", description, Email "marta@renew.example" read-only with help "From your reset
link.", New password empty with checklist "At least 8 characters" unmet, Confirm new password
empty with checklist "Passwords match" unmet, submit "Change password".
2. **Too short state** — from state 1, set New password and Confirm new password both to "lime";
submit; assert `POST /v1/auth/password-reset/confirm` was never called; assert "At least 8
characters" shows its error state and "Passwords match" shows its met state.
3. **Mismatch state** — from state 1, set New password to "limewash2026", type "limewash2025"
into Confirm new password and blur; assert the error "The passwords don't match." and the
"Passwords match" checklist error state; submit; assert the call was never made and the same
error is still shown.
4. **Submitting state** — from state 1, set both password fields to "limewash2026"; mock the call
with an unresolved promise; submit; assert both fields are disabled and the submit button
reads "Changing password…" with a loading indicator.
5. **Success state** — from state 1, set both password fields to "limewash2026"; mock the call
resolving 200; submit; assert the router's current path is "/login?reason=password-changed"
(the rendering of that page is covered by `login_screen.qa.md`, not re-asserted here beyond
the path/query).
6. **Invalid or expired state** — from state 1, set both password fields to "limewash2026"; mock
the call resolving 403 with the `apperr.PasswordResetFailedErrorMessage` body; submit; assert
the form is no longer rendered; assert a ResultPanel (error, link-2-off, "This reset link has
expired", the text above) with actions "Request a new link" (primary) and "Back to log in"
(secondary); click each in separate sub-cases and assert the router's current path is
/register/reset/request and /login respectively.
7. **Server or network error state** — two sub-cases (500 response, rejected fetch) from state 1,
with both password fields set to "limewash2026"; submit; assert an error alert with icon
"i-lucide-circle-x", title "Couldn't reach RenewCMS", description "Check your connection and
try again." (the same copy `login_screen.qa.md` and `register_screen.qa.md` already assert
for their own server/network cases); assert the form is still rendered (no ResultPanel);
assert both password fields still read "limewash2026" and are re-enabled; assert the submit
button reads "Change password" and is no longer loading.
8. **Missing parameters state** — three sub-cases, one per Examples row (missing "c", missing
"email", missing both); mount at the row's path; assert no request was made; assert a
ResultPanel (error, link-2-off, "This link is incomplete", the text above) with a single
primary action "Request a new link"; click it and assert the router's current path is
/register/reset/request.
| path |
|----------------------------------------------------------|
| /register/reset/validate?email=marta%40renew.example |
| /register/reset/validate?c=abc123 |
| /register/reset/validate |
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Reset password". Run
`task visual:check -- reset-password` for each state.
- [ ] Default state: read-only Email field styling, both checklist items in their unmet/gray
state, matches the board exactly.
- [ ] Too short state: New password's checklist item in its error/red state while Confirm new
password's stays met/green, since both fields match.
- [ ] Mismatch state: inline field error under Confirm new password plus its checklist error
state.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Invalid or expired state: ResultPanel with both actions, primary first (components.md's
safe/primary-on-top rule for stacked mobile buttons).
- [ ] Server or network error state: error alert (circle-x icon, no close button) above the
heading, matching Login's/Register's own server-error alert styling exactly, since this
screen has no board state of its own for it; fields retain their values. This state is not
covered by `task visual:check`'s per-state board comparison (no board frame exists for it);
note that gap in the run's report rather than skipping it silently.
- [ ] Missing parameters state: ResultPanel with a single primary action.
- [ ] Keyboard/focus: Tab order is New password → show/hide → Confirm new password → show/hide →
submit; the ResultPanel's title receives focus on both the Invalid-or-expired and
Missing-parameters states; the mismatch error and the server/network alert are announced
without stealing focus from the field or the heading, respectively.
## Manual verification
- **Real POST /v1/auth/password-reset/confirm against a running server** — Vitest only mocks
`useApi`; confirm the real 200/403 round-trip (including the actual bcrypt code check, 1-hour
expiry, and single-use clearing) against `task dev` with a live SQLite file. The endpoint's own
contract is already covered by `features/reset_password.qa.md`; this slice only re-verifies the
screen consumes it correctly.
+85
View File
@@ -0,0 +1,85 @@
Feature: Password reset request screen
As a RenewCMS user who forgot their password
I want a /register/reset/request screen that emails a reset link via POST
/v1/auth/password-reset, validates the email client-side before sending, and never reveals
whether the address has an account
So that I can ask for a reset link with the same neutral confirmation regardless of whether my
account exists, and see plainly when I'm asking too often or the server failed
Background:
Given /register/reset/request is a public route (meta.auth "public") rendered as
ResetRequestPage inside AuthShell, per docs/design/routes.md
And ResetRequestPage only calls POST /v1/auth/password-reset
And the form has a single Email field (placeholder "you@example.com")
And client-side validation runs on submit only, before any request is sent: Email must be a
well-formed email address (the same pattern RegisterPage already uses), and a failing Email
shows "Enter a full email address, like name@example.com."
And a valid submit calls POST /v1/auth/password-reset with { email }
And every response the API gives — whether the email belongs to an account or not — is the
same 200 with the same body, per features/request_password_reset.feature, so the screen
shows the same Sent state either way
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 same mechanism LoginPage's own 429 handling
uses, and the Email field is disabled for the same duration
And a 500 response, or a request that never reaches the server, is surfaced as a
server/network error; the field keeps 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 required,email rule the API enforces; it is out of scope for this slice
Scenario: Default — a fresh, empty form
When the user navigates to /register/reset/request
Then the heading is "Reset your password"
And the description is "Enter the email for your account and we'll send you a link to choose
a new password."
And the Email field shows placeholder "you@example.com" with no value
And the submit button reads "Send reset link"
And the footer reads "Back to log in" linking to /login
Scenario: Validation error — an invalid email blocks the request
Given the user is on /register/reset/request
When the user types "marta@renew" into Email and submits
Then POST /v1/auth/password-reset is never called
And the Email field shows the error "Enter a full email address, like name@example.com."
Scenario: Submitting — the field and button are disabled while the request is in flight
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset has not yet resolved
Then the Email field is disabled
And the submit button reads "Sending…" and shows a loading state
Scenario: Sent — any 200 shows the same neutral confirmation
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 200
Then the form is replaced by a ResultPanel with tone "success", icon "i-lucide-mail-check",
title "Check your email", text "If an account exists for that email, a password reset link
has been sent.", and note "The link expires in 1 hour."
And its single action is a secondary "Back to log in"
When the user activates "Back to log in"
Then the app navigates to /login
Scenario: Too many attempts — the button is disabled with a live countdown
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset resolves 429 with a Retry-After of 58
seconds
Then an info alert reads "Too many requests" with description "Wait a moment before asking
for another link."
And the Email field still shows "marta@renew.example"
And the submit button reads "Try again in 0:58" and is disabled
And the Email field is disabled while the countdown runs
When 58 seconds elapse
Then the submit button re-enables and reads "Send reset link"
And the Email field is editable again
Scenario Outline: Server or network error — the field keeps its value and re-enables
Given the user is on /register/reset/request with Email "marta@renew.example"
When the user submits and POST /v1/auth/password-reset <failure>
Then an error alert reads "Couldn't send the link" with description "Check your connection
and try again."
And the Email field still shows "marta@renew.example" and is re-enabled
And the submit button reads "Send reset link" again, no longer loading
Examples:
| failure |
| fails with a 500 response |
| never reaches the server |
+59
View File
@@ -0,0 +1,59 @@
# QA: Password reset request screen
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` (`POST /v1/auth/password-reset`) per case; no real backend, no httptest server.
Mount ResetRequestPage inside AuthShell with a test router.
1. **Default state** — mount at /register/reset/request; assert heading "Reset your password",
description, Email placeholder "you@example.com" with no value, submit "Send reset link",
footer "Back to log in" linking to /login.
2. **Validation error state** — set Email to "marta@renew", submit; assert the mocked
`POST /v1/auth/password-reset` was never called; assert the error "Enter a full email
address, like name@example.com."
3. **Submitting state** — set Email "marta@renew.example"; mock the call with an unresolved
promise; submit; assert the Email field is disabled and the submit button reads "Sending…"
with a loading indicator.
4. **Sent state** — set Email "marta@renew.example"; mock the call resolving 200; submit; assert
the form is replaced by a ResultPanel (success, mail-check, "Check your email", the neutral
text, note "The link expires in 1 hour.") with a single secondary action "Back to log in";
click it and assert the router's current path is /login.
5. **Too many attempts state** — set Email "marta@renew.example"; mock the call resolving 429
with `Retry-After: 58`; submit; assert an info alert ("Too many requests", "Wait a moment
before asking for another link."); assert Email still reads "marta@renew.example"; assert the
submit button reads "Try again in 0:58" and is disabled; assert the Email field is disabled;
advance fake timers 58s; assert the button re-enables and reads "Send reset link" and the
field is editable again.
6. **Server or network error state** — two sub-cases (500 response, rejected fetch) with Email
"marta@renew.example"; submit; assert an error alert ("Couldn't send the link", "Check your
connection and try again."); assert Email still reads "marta@renew.example" and is re-enabled;
assert the submit button reads "Send reset link" and is no longer loading.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Forgot password". Run
`task visual:check -- forgot-password` for each state.
- [ ] Default state: heading, description, Email field, "Send reset link" button, "Back to log
in" footer link, matches the board exactly.
- [ ] Validation error state: the inline field error renders with the same styling as
Register's/Login's own field errors.
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
- [ ] Sent state: ResultPanel (mail-check icon) with its single secondary action, note text below
the description.
- [ ] Too many attempts state: info alert (timer icon), disabled field and disabled button with
the live "Try again in m:ss" countdown text.
- [ ] Server error state: error alert (circle-x icon, no close button), field retains its value.
- [ ] Keyboard/focus: Tab order is Email → submit; the alert (when present) is announced via
`role="status"` (info) or `role="alert"` (error) without re-announcing on re-render; the
ResultPanel's title receives focus on the Sent state.
## Manual verification
- **Real POST /v1/auth/password-reset against a running server** — Vitest only mocks `useApi`;
confirm the real 200/429/500 round-trip (including the actual rate limiter and Retry-After
header) against `task dev` with a live SQLite file. The endpoint's own contract is already
covered by `features/request_password_reset.qa.md`; this slice only re-verifies the screen
consumes it correctly.
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift against a
real elapsed 58-second wait, since Vitest fake timers only prove the logic.
+263 -2
View File
@@ -1,9 +1,270 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import PasswordStrengthHint from '@/components/PasswordStrengthHint.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
const route = useRoute()
const router = useRouter()
const api = useApi()
const code = typeof route.query.c === 'string' ? route.query.c : ''
const email = typeof route.query.email === 'string' ? route.query.email : ''
const missingParameters = !code || !email
const password = ref('')
const confirmPassword = ref('')
const passwordShown = ref(false)
const confirmPasswordShown = ref(false)
const submitting = ref(false)
const submitFailed = ref(false)
const mismatchTouched = ref(false)
const serverErrorAlert = ref(false)
const expired = ref(false)
const passwordValid = computed(() => password.value.length >= 8)
const passwordsMatch = computed(() => confirmPassword.value === password.value)
const showLengthError = computed(() => submitFailed.value && !passwordValid.value)
const showMatchError = computed(() => mismatchTouched.value && !passwordsMatch.value)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const confirmPasswordRules = computed(() => [
{ label: 'Passwords match', met: passwordsMatch.value },
])
const confirmError = computed(() => (showMatchError.value ? "The passwords don't match." : ''))
function goToRequest() {
router.push('/register/reset/request')
}
function goToLogin() {
router.push('/login')
}
const expiredActions = [
{ label: 'Request a new link', onClick: goToRequest },
{
label: 'Back to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
const missingParametersActions = [{ label: 'Request a new link', onClick: goToRequest }]
const confirmDescription =
"Pick something you haven't used here before. You'll log in with it next."
async function onSubmit() {
if (submitting.value) {
return
}
mismatchTouched.value = true
if (!passwordValid.value || !passwordsMatch.value) {
submitFailed.value = true
return
}
serverErrorAlert.value = false
submitting.value = true
try {
await api.request('/v1/auth/password-reset/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
code,
password: password.value,
}),
})
await router.push('/login?reason=password-changed')
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
expired.value = true
return
}
serverErrorAlert.value = true
} finally {
submitting.value = false
}
}
</script>
<template>
<AuthShell title="Choose a new password">
<p>Reset confirm placeholder.</p>
<AuthShell
:title="expired || missingParameters ? '' : 'Choose a new password'"
:description="expired || missingParameters ? undefined : confirmDescription"
>
<template v-if="serverErrorAlert" #notice>
<UAlert
color="error"
icon="i-lucide-circle-x"
title="Couldn't reach RenewCMS"
description="Check your connection and try again."
variant="soft"
:close="false"
role="alert"
/>
</template>
<template v-if="missingParameters" #default>
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This link is incomplete"
description="Part of the reset link is missing. Open it from your email again, or request a new one."
:actions="missingParametersActions"
/>
</template>
<template v-else-if="expired" #default>
<ResultPanel
tone="error"
icon="i-lucide-link-2-off"
title="This reset link has expired"
description="Reset links work once, for 1 hour. Request a new one and use it within the hour."
:actions="expiredActions"
/>
</template>
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm
:state="{ email, password, confirmPassword }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
help="From your reset link."
:size="controlSize"
>
<UInput
:model-value="email"
placeholder="you@example.com"
readonly
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} bg-elevated text-dimmed ring-(--ui-border)`,
}"
class="w-full"
/>
</UFormField>
<UFormField label="New password" name="password" :size="controlSize">
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
:show-errors="showLengthError"
/>
</template>
<UInput
id="reset-new-password"
v-model="password"
:type="passwordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:size="controlSize"
:ui="{
base: `${inputClass} ${passwordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
</template>
</UInput>
</UFormField>
<UFormField label="Confirm new password" name="confirmPassword" :size="controlSize">
<template #help>
<span
v-if="confirmError"
role="alert"
class="mb-1.5 flex items-center gap-1.5 text-xs/[18px] text-error"
>
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ confirmError }}
</span>
<PasswordStrengthHint
:rules="confirmPasswordRules"
:show-errors="showMatchError"
/>
</template>
<UInput
id="reset-confirm-password"
v-model="confirmPassword"
:type="confirmPasswordShown ? 'text' : 'password'"
autocomplete="new-password"
:disabled="submitting"
variant="outline"
color="primary"
:highlight="!!confirmError"
:size="controlSize"
:ui="{
base: `${inputClass} ${confirmPasswordShown ? '' : 'tracking-[2px]'}`,
trailing: 'pe-1',
}"
class="w-full"
@blur="mismatchTouched = true"
>
<template #trailing>
<UButton
:icon="confirmPasswordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="
confirmPasswordShown ? 'Hide password' : 'Show password'
"
:aria-pressed="confirmPasswordShown"
@click="confirmPasswordShown = !confirmPasswordShown"
/>
</template>
</UInput>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitting"
>
{{ submitting ? 'Changing password…' : 'Change password' }}
</UButton>
</UForm>
</template>
</AuthShell>
</template>
+192 -2
View File
@@ -1,9 +1,199 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import AuthShell from '@/components/AuthShell.vue'
import ResultPanel from '@/components/ResultPanel.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
interface AlertState {
tone: 'error' | 'info'
icon: string
title: string
description: string
}
const tooManyAttemptsAlert: AlertState = {
tone: 'info',
icon: 'i-lucide-timer',
title: 'Too many requests',
description: 'Wait a moment before asking for another link.',
}
const serverErrorAlert: AlertState = {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
}
const router = useRouter()
const api = useApi()
const retryAfter = useRetryAfter()
const email = ref('')
const emailError = ref('')
const submitting = ref(false)
const sent = ref(false)
const alert = ref<AlertState | null>(null)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
const submitLabel = computed(() => {
if (submitting.value) {
return 'Sending…'
}
if (retryAfter.active.value) {
return `Try again in ${retryAfter.formatted.value}`
}
return 'Send reset link'
})
function validate() {
emailError.value = EMAIL_PATTERN.test(email.value)
? ''
: 'Enter a full email address, like name@example.com.'
return !emailError.value
}
function goToLogin() {
router.push('/login')
}
const resultPanelActions = [
{
label: 'Back to log in',
color: 'neutral' as const,
variant: 'outline' as const,
onClick: goToLogin,
},
]
function handleRequestError(error: unknown) {
if (error instanceof ApiError && error.status === 429) {
alert.value = tooManyAttemptsAlert
retryAfter.start(error.retryAfter ?? 0)
return
}
alert.value = serverErrorAlert
}
async function onSubmit() {
if (submitDisabled.value) {
return
}
if (!validate()) {
return
}
alert.value = null
submitting.value = true
try {
await api.request('/v1/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
sent.value = true
} catch (error) {
handleRequestError(error)
} finally {
submitting.value = false
}
}
const pageTitle = computed(() => (sent.value ? '' : 'Reset your password'))
const pageDescription = computed(() =>
sent.value
? undefined
: "Enter the email for your account and we'll send you a link to choose a new password.",
)
</script>
<template>
<AuthShell title="Reset your password">
<p>Reset request placeholder.</p>
<AuthShell :title="pageTitle" :description="pageDescription">
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
:icon="alert.icon"
:title="alert.title"
:description="alert.description"
variant="soft"
:close="false"
:role="alertRole"
/>
</template>
<template v-if="sent" #default>
<ResultPanel
tone="success"
icon="i-lucide-mail-check"
title="Check your email"
description="If an account exists for that email, a password reset link has been sent."
:actions="resultPanelActions"
>
<p class="text-sm text-muted">The link expires in 1 hour.</p>
</ResultPanel>
</template>
<template v-else #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ email }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UFormField
label="Email"
name="email"
:error="emailError || undefined"
:size="controlSize"
>
<template v-if="emailError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<UInput
v-model="email"
type="email"
placeholder="you@example.com"
autocomplete="email"
:disabled="submitDisabled"
variant="outline"
color="primary"
:highlight="!!emailError"
:size="controlSize"
:ui="{ base: inputClass, trailing: 'pe-1' }"
class="w-full"
/>
</UFormField>
<UButton
type="submit"
block
:size="controlSize"
:ui="{ base: controlClass }"
:loading="submitting"
:disabled="submitDisabled"
>
{{ submitLabel }}
</UButton>
</UForm>
</template>
<template v-if="!sent" #footer>
<RouterLink
to="/login"
class="font-medium text-highlighted underline underline-offset-3"
>
Back to log in
</RouterLink>
</template>
</AuthShell>
</template>
@@ -0,0 +1,326 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import ResetConfirmView from '../ResetConfirmView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountResetConfirm(
path: string,
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push(path)
const wrapper = mount(ResetConfirmView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
const DEFAULT_PATH = '/register/reset/validate?c=abc123&email=marta%40renew.example'
function newPasswordInput(wrapper: Awaited<ReturnType<typeof mountResetConfirm>>) {
return wrapper.find('#reset-new-password')
}
function confirmPasswordInput(wrapper: Awaited<ReturnType<typeof mountResetConfirm>>) {
return wrapper.find('#reset-confirm-password')
}
async function fillPasswords(
wrapper: Awaited<ReturnType<typeof mountResetConfirm>>,
password: string,
confirm: string,
) {
await newPasswordInput(wrapper).setValue(password)
await confirmPasswordInput(wrapper).setValue(confirm)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — both parameters present, an untouched form', () => {
it('renders the empty form', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH)
expect(wrapper.find('h1').text()).toBe('Choose a new password')
expect(wrapper.text()).toContain(
"Pick something you haven't used here before. You'll log in with it next.",
)
const emailField = wrapper.find('input[placeholder="you@example.com"]')
expect((emailField.element as HTMLInputElement).value).toBe('marta@renew.example')
expect((emailField.element as HTMLInputElement).readOnly).toBe(true)
expect(wrapper.text()).toContain('From your reset link.')
expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('At least 8 characters')
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).toContain('Passwords match')
expect(wrapper.find('button[type="submit"]').text()).toBe('Change password')
})
})
describe('Too short — fewer than 8 characters blocks submission', () => {
it('never calls the endpoint and marks the length rule as errored', async () => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await fillPasswords(wrapper, 'lime', 'lime')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).not.toHaveBeenCalled()
const items = wrapper.findAll('li')
const lengthItem = items.find((item) => item.text().includes('At least 8 characters'))
expect(lengthItem?.text()).toContain('not met')
const matchItem = items.find((item) => item.text().includes('Passwords match'))
expect(matchItem?.text()).toContain('met')
})
})
describe('Mismatch — caught on blur and on submit', () => {
it('shows the mismatch error and blocks the request', async () => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await newPasswordInput(wrapper).setValue('limewash2026')
await confirmPasswordInput(wrapper).setValue('limewash2025')
await confirmPasswordInput(wrapper).trigger('blur')
expect(wrapper.text()).toContain("The passwords don't match.")
const matchItemAfterBlur = wrapper
.findAll('li')
.find((item) => item.text().includes('Passwords match'))
expect(matchItemAfterBlur?.find('svg').classes()).toContain('text-error')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).not.toHaveBeenCalled()
expect(wrapper.text()).toContain("The passwords don't match.")
const matchItemAfterSubmit = wrapper
.findAll('li')
.find((item) => item.text().includes('Passwords match'))
expect(matchItemAfterSubmit?.find('svg').classes()).toContain('text-error')
})
})
describe('Submitting — fields and button are disabled while the request is in flight', () => {
it('disables the fields and shows the loading label', async () => {
let resolveConfirm: (() => void) | null = null
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () =>
new Promise((resolve) => {
resolveConfirm = () => resolve(mockResponse(200))
}),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Changing password…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveConfirm?.()
await flushPromises()
})
})
describe('Success — a 200 hands off to the log-in screen', () => {
it('navigates to /login?reason=password-changed', async () => {
const { wrapper, fetchMock } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(200),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/login?reason=password-changed'),
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/password-reset/confirm',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
email: 'marta@renew.example',
code: 'abc123',
password: 'limewash2026',
}),
}),
)
})
})
describe('Invalid or expired — a 403 replaces the form with a ResultPanel', () => {
it('shows the panel and navigates depending on the action', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(403),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This reset link has expired')
expect(wrapper.text()).toContain(
'Reset links work once, for 1 hour. Request a new one and use it within the hour.',
)
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('error')
expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off')
const buttons = wrapper.findAll('button')
const requestNew = buttons.find((button) => button.text() === 'Request a new link')
expect(requestNew).toBeTruthy()
await requestNew?.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/reset/request'),
)
})
it('navigates to /login when Back to log in is activated', async () => {
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': () => mockResponse(403),
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
const buttons = wrapper.findAll('button')
const backToLogin = buttons.find((button) => button.text() === 'Back to log in')
expect(backToLogin).toBeTruthy()
await backToLogin?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Server or network error — the form keeps its values and re-enables', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the values for $name', async ({ handler }) => {
const confirm = vi.fn(handler)
const { wrapper } = await mountResetConfirm(DEFAULT_PATH, {
'POST /v1/auth/password-reset/confirm': confirm,
})
await fillPasswords(wrapper, 'limewash2026', 'limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(confirm).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(wrapper.find('form').exists()).toBe(true)
expect((newPasswordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
expect((newPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((confirmPasswordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Change password')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
describe('Missing parameters — either missing parameter blocks the request', () => {
it.each([
{
path: '/register/reset/validate?email=marta%40renew.example',
},
{ path: '/register/reset/validate?c=abc123' },
{ path: '/register/reset/validate' },
])('renders the missing-parameters panel for $path', async ({ path }) => {
const confirm = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetConfirm(path, {
'POST /v1/auth/password-reset/confirm': confirm,
})
expect(confirm).not.toHaveBeenCalled()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('This link is incomplete')
expect(wrapper.text()).toContain(
'Part of the reset link is missing. Open it from your email again, or request a new one.',
)
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('error')
expect(resultPanel.props('icon')).toBe('i-lucide-link-2-off')
expect(resultPanel.props('actions')).toHaveLength(1)
const action = wrapper.find('button')
expect(action.text()).toBe('Request a new link')
await action.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/reset/request'),
)
})
})
@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import ResetRequestView from '../ResetRequestView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountResetRequest(
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
await router.push('/register/reset/request')
const wrapper = mount(ResetRequestView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return { wrapper, fetchMock }
}
function emailInput(wrapper: Awaited<ReturnType<typeof mountResetRequest>>) {
return wrapper.find('input[placeholder="you@example.com"]')
}
async function fillAndSubmit(
wrapper: Awaited<ReturnType<typeof mountResetRequest>>,
email: string,
) {
await emailInput(wrapper).setValue(email)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
await router.push('/login')
})
describe('Default — a fresh, empty form', () => {
it('renders the empty state', async () => {
const { wrapper } = await mountResetRequest()
expect(wrapper.find('h1').text()).toBe('Reset your password')
expect(wrapper.text()).toContain(
"Enter the email for your account and we'll send you a link to choose a new password.",
)
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link')
const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true)
expect(footerLink.text()).toBe('Back to log in')
})
})
describe('Validation error — an invalid email blocks the request', () => {
it('never calls the endpoint and shows the field error', async () => {
const request = vi.fn(() => mockResponse(200))
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': request,
})
await fillAndSubmit(wrapper, 'marta@renew')
expect(request).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
})
})
describe('Submitting — the field and button are disabled while the request is in flight', () => {
it('disables the field and shows the loading label', async () => {
let resolveRequest: (() => void) | null = null
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': () =>
new Promise((resolve) => {
resolveRequest = () => resolve(mockResponse(200))
}),
})
await emailInput(wrapper).setValue('marta@renew.example')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Sending…')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
resolveRequest?.()
await flushPromises()
})
})
describe('Sent — any 200 shows the same neutral confirmation', () => {
it('replaces the form with a ResultPanel and navigates back to log in', async () => {
const { wrapper, fetchMock } = await mountResetRequest({
'POST /v1/auth/password-reset': () => mockResponse(200),
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/password-reset',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ email: 'marta@renew.example' }),
}),
)
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('Check your email')
expect(wrapper.text()).toContain(
'If an account exists for that email, a password reset link has been sent.',
)
expect(wrapper.text()).toContain('The link expires in 1 hour.')
const resultPanel = wrapper.findComponent(ResultPanel)
expect(resultPanel.props('tone')).toBe('success')
expect(resultPanel.props('icon')).toBe('i-lucide-mail-check')
const action = wrapper.find('button')
expect(action.text()).toBe('Back to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
describe('Too many attempts — the button is disabled with a live countdown', () => {
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': () => mockResponse(429, null, { 'Retry-After': '58' }),
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(wrapper.text()).toContain('Too many requests')
expect(wrapper.text()).toContain('Wait a moment before asking for another link.')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:58')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
await vi.advanceTimersByTimeAsync(58000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Send reset link')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
vi.useRealTimers()
})
})
describe('Server or network error — the field keeps its value and re-enables', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the value for $name', async ({ handler }) => {
const request = vi.fn(handler)
const { wrapper } = await mountResetRequest({
'POST /v1/auth/password-reset': request,
})
await fillAndSubmit(wrapper, 'marta@renew.example')
expect(request).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't send the link")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Send reset link')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})
+109
View File
@@ -392,6 +392,51 @@ async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
async function fillResetRequestEmail(page: Page, email: string) {
await page.getByPlaceholder('you@example.com').fill(email)
}
function submitResetRequest(email: string) {
return async (page: Page) => {
await fillResetRequestEmail(page, email)
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetRequestWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register/reset/request',
api: {
'POST /v1/auth/password-reset': response,
},
}
}
const resetConfirmPath = '/register/reset/validate?c=abc123&email=marta%40renew.example'
async function fillResetConfirm(page: Page, password: string, confirmPassword: string) {
await page.locator('#reset-new-password').fill(password)
await page.locator('#reset-confirm-password').fill(confirmPassword)
}
function submitResetConfirm(password: string, confirmPassword: string) {
return async (page: Page) => {
await fillResetConfirm(page, password, confirmPassword)
await page.getByRole('button', { name: 'Change password', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetConfirmWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': response,
},
}
}
const integrationOrigin = 'https://cms.renew.example'
async function clickBaseUrlCopy(page: Page) {
@@ -1022,4 +1067,68 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/register/validate',
},
},
'forgot-password': {
Default: { path: '/register/reset/request' },
'Validation error': {
path: '/register/reset/request',
steps: submitResetRequest('marta@renew'),
},
Submitting: {
...resetRequestWith({ status: 200, hang: true }),
steps: submitResetRequest('marta@renew.example'),
},
Sent: {
...resetRequestWith({ status: 200, body: { message: 'ok' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Too many attempts (429)': {
...resetRequestWith({ status: 429, headers: { 'Retry-After': '58' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Server error': {
...resetRequestWith({ status: 500 }),
steps: submitResetRequest('marta@renew.example'),
},
},
'reset-password': {
Default: { path: resetConfirmPath },
'Too short': {
path: resetConfirmPath,
steps: submitResetConfirm('lime', 'lime'),
},
Mismatch: {
path: resetConfirmPath,
steps: async (page) => {
await fillResetConfirm(page, 'limewash2026', 'limewash2025')
await page.locator('#reset-confirm-password').blur()
},
},
Submitting: {
...resetConfirmWith({ status: 200, hang: true }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
Success: {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/registration': registrationClosed,
},
steps: async (page) => {
await submitResetConfirm('limewash2026', 'limewash2026')(page)
await page.waitForURL('**/login**')
await page.getByText('Password changed').waitFor()
},
},
'Invalid or expired (403)': {
...resetConfirmWith({ status: 403 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
'Missing parameters': {
path: '/register/reset/validate',
},
'Server error': {
...resetConfirmWith({ status: 500 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
},
}