From e218b4ebdf7381a2331ff6441164927973a0eacc Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Mon, 13 Jul 2026 06:30:47 -0700 Subject: [PATCH] fix: make password reset requests honest --- GITHUB_ISSUES.md | 2 +- GITHUB_ISSUE_SLICES.md | 5 +- OPERATIONS_RUNBOOK.md | 8 +- SECURITY.md | 2 +- docs/officers/EMERGENCY_AND_RECOVERY.md | 46 ++++ docs/officers/EVENTS_SHOP_MEMBERS.md | 2 + src/pages/login/LoginForm.css | 70 +++++ src/pages/login/LoginForm.jsx | 165 ++++++++++-- src/pages/login/LoginForm.test.jsx | 332 +++++++++++++++++++++++- 9 files changed, 608 insertions(+), 24 deletions(-) diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 05c8c547..22d9b526 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -328,7 +328,7 @@ Both the legacy member synchronization function and admin role callable grant cl AUTH-001A now rejects unverified targets at the two existing role-grant endpoints. Do not duplicate that merged slice. It is not proof that Firebase production runs the new code, and it does not complete the remaining parent requirements below. -Adjacent account-email recovery is represented by canonical live parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120), not by a new AUTH-001 role-grant child. AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged the account-creation/request split as `46557c7` but is not published. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) owns the truthful My Account resend result and browser cooldown. AUTH-MAIL-002C remains proposed for password-reset/action-code recovery. None grants membership or proves provider delivery. +Adjacent account-email recovery is represented by canonical live parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120), not by a new AUTH-001 role-grant child. AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged the account-creation/request split as `46557c7` but is not published. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged the truthful My Account resend result and browser cooldown as `23bca8c8`, but protected release run `29252492614` stopped before build and published nothing. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one neutral password-reset request result and repeat-safe browser cooldown. Incoming action-code states remain proposed as C2. None grants membership, proves provider delivery, or proves Firebase API enumeration protection. ### Scope diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 691b051b..093f0520 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -37,8 +37,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | AUTH-003C | owner_action; AUTH-003B | Enforce privileged MFA, recent-auth thresholds, revocation, and break-glass/last-admin policy. | Staging failure/success demonstrations and private owner inventory. | | AUTH-003D | proposed; AUTH-003B/C | Migrate admin UI/routes/token refresh, prove parity, then remove legacy alias. | Route/capability/demotion tests and staged operator sign-off. | | AUTH-MAIL-002A | source merged through [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) / PR #148 as `46557c7`; website/provider/live unproven | Separate account creation from verification-request acceptance/failure without exposing provider details. | Mocked create/accepted/unavailable tests; exact website and provider evidence stay separate. | -| AUTH-MAIL-002B | source/tests tracked in live [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153); A, #118 source; not published | Make My Account resend report request acceptance or generic unavailability, block rapid repeats, and show the same 60-second browser cooldown after either result. | No-network double-click, timer/retry, remount, UID-change, redaction, and accessibility tests; website/provider/live states remain separate. | -| AUTH-MAIL-002C | proposed; A/B | Give password-reset and Auth action-code flows generic, enumeration-resistant success/expired/used/wrong-account/offline/provider-unavailable states. | No-network state/collision/navigation/accessibility tests with identical public recovery results where required. | +| AUTH-MAIL-002B | source/tests merged through [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) / PR #154 as `23bca8c8`; A, #118 source; release `29252492614` stopped before build; not published | Make My Account resend report request acceptance or generic unavailability, block rapid repeats, and show the same 60-second browser cooldown after either result. | No-network double-click, timer/retry, remount, UID-change, redaction, and accessibility tests; website/provider/live states remain separate. | +| AUTH-MAIL-002C1 | source/tests tracked in live [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155); A/B; not published | Give every password-reset provider outcome one byte-equivalent neutral result, immediate repeat protection, and the same 60-second browser wait. Do not claim direct-API enumeration protection. | No-network success/failure equivalence, hostile canary, double-click, deadline/retry, unmount/remount, route/input, and accessibility tests; private provider-policy evidence stays #119. | +| AUTH-MAIL-002C2 | proposed; C1 | Add generic expired, used, already-complete, wrong-account, malformed, offline, and provider-unavailable states for incoming Firebase Auth action-code links without leaking codes or account state. | No-network state/collision/navigation/accessibility tests with fixed public recovery outcomes and callback-token redaction. | | OAUTH-001A | proposed; represented within canonical live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); SEC-001, AUTH-003B | Implement server-only, transaction/version-safe Strava token refresh so rotated refresh tokens cannot be lost. Do not publish a duplicate; claim/split only through #88. | Concurrent refresh/retry/conflict emulator tests. | | OAUTH-001B | owner_action; represented within canonical live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); OAUTH-001A | Minimize scopes, implement disconnect/provider revocation, record encryption decision, and add access/refresh/revoke audit. Do not publish a duplicate; claim/split only through #88. | Test-provider revoke/refresh evidence and approved threat decision. | | OAUTH-001C | proposed; represented within canonical live [#88](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/88); #99 | Create a server-safe Strava callback handoff that keeps `code`/`state` out of third parties while allowing App Check-protected `stravaExchangeCode`; retain state verification and server-only token exchange. Do not publish a duplicate; claim/split only through #88. | Encoded/case/trailing callback tests, wrong/replayed state denial, missing/invalid/valid App Check tests, and test-provider-only exchange evidence. | diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index 80c228cc..0d6ed991 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -588,7 +588,9 @@ AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) adds the My Account source state machine: idle, requesting, accepted, or unavailable. Request acceptance is not delivery. A synchronous guard blocks rapid duplicate activation. Both completed outcomes start the same visible 60-second wait, then allow one retry. Remounting the page or changing the signed-in account resets this browser-only timer; Firebase can still throttle requests. The timer is not server authorization or abuse protection. -The source and tests do not change Firebase Auth, email templates, DNS, sender branding, or mailbox/provider settings. Tests use a mocked request and no network. Do not test this flow with a production mailbox or a real member. +AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks the password-reset request source state machine: idle, requesting, finished, and retry-ready. Provider success and failure enter the same `finished` DOM, copy, accessibility state, and 60-second deadline. The browser never says sent, delivered, accepted, failed, or whether an account exists. Missing local input makes no request. A synchronous ref blocks duplicate activation; unmount discards a late result and clears the timer. Remounting may reset this usability guard. + +The source and tests do not change Firebase Auth, email templates, DNS, sender branding, account-enumeration protection, or mailbox/provider settings. Tests use a mocked request and no network. Identical browser output does not prove the direct Firebase API hides account state; the identity owner must record that provider setting privately under [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). Do not test these flows with a production mailbox or a real member. Release and evidence order: @@ -600,7 +602,7 @@ Release and evidence order: 6. Use an isolated staging Firebase project and approved safe email sink before any end-to-end test. 7. Record request acceptance separately from message delivery, Spam placement, and sender configuration. 8. Keep provider/DNS/template work under [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). -9. Keep password/action-code recovery in AUTH-MAIL-002C under parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120). #153 changes only the browser resend result and usability timer; it does not prove provider delivery or impose a durable rate limit. +9. Keep password reset request work in AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) and incoming action-code work in the future AUTH-MAIL-002C2 under parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120). #153/#155 change browser results and usability timers only; they do not prove provider delivery, enumeration protection, or a durable rate limit. Rollback is a reviewed frontend revert or safe roll-forward. Do not delete or recreate an Auth account because the verification request failed. @@ -628,4 +630,6 @@ For AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/ For AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153), **source/tests** means mocked resend outcomes, double-click/ref/timer/UID cleanup, fixed-output canaries, and accessible status/action checks; **merge** is a separate `main` SHA; **website publication** and **`runmprc.com` revision verification** are separate; **Firebase/provider configuration** is unchanged; and **delivery** remains unverified. The 60-second display resets with browser component/session changes and is not provider throttling proof. +For AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155), **source/tests** means mocked reset success/failure produce byte-equivalent neutral public output, immediate repeat protection, deadline/retry/unmount tests, return-state preservation, hostile-canary redaction, and accessible controls; **merge** is a separate `main` SHA; **website publication** and **`runmprc.com` revision verification** are separate; **Firebase/provider configuration** is unchanged; and **delivery plus API enumeration protection** remain unverified. A neutral page does not prove provider policy or direct-API behavior. + Provider configuration, production secrets, Netlify publication, Firebase deployment, and live behavior always require separate dated evidence. Local tests and a green GitHub summary do not prove them. diff --git a/SECURITY.md b/SECURITY.md index 42b2d48c..4fdb6abc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -108,7 +108,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Part of RISK-035 | The Jest environment is repaired, the stale frontend smoke test is replaced, #124 adds the complete frontend suite as a named blocking hosted step, and #126 adds the standalone SPA callback suite as its own named blocking step. | Keep exact hosted evidence on #124/#126; make lint non-mutating/fail-closed, prove required branch checks, and add the broader TEST-001 suite. | | RISK-026, RISK-036 | #135 merged through PR #138 as `9eafab1217aff7058c42240aaba72d7b93f8ed24`, replacing automatic frontend-first/fail-open GitHub deployment with a tested manual exact-current-commit gate. Post-merge staging and synthetic production probes failed closed before authentication or mutation, and published neither Firebase nor Pages. | #133 must configure protected environments and least-privilege OIDC/WIF; #136 must prove staged/target deployment and clearing/readback of the existing Pages `runmprc.com` claim; a protected WEB-001 child must establish Netlify publication and rollback. No Firebase, Pages, live-host, or provider-setting change is proven by source/static tests alone. | | RISK-039 | The #118 source slice uses an empty authenticated callable request, bounded Firebase Auth identity fields, one transactional create-only helper shared with signup, constant responses, generic failures, and a UI that hides Edit until setup and the Rules-protected read succeed. Signup and recovery never change custom claims; browser profile creation remains denied. | Source review/merge is not deployment. Under #105, deploy the exact #100 Rules plus both `createMemberOnSignUp` and `ensureMemberProfile` before the website, prove App Check policy, use a synthetic staged account, verify rollback, then record website, Function, Rules, and live behavior separately. Never repair a real profile manually. | -| RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details, but that website revision is not published. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) removes My Account's false “sent” claim, adds a synchronous repeat guard, and applies the same 60-second browser cooldown after request acceptance or generic failure. Synthetic tests use mocked Firebase calls and canary values only. | Publish and verify each exact frontend revision separately. #118 profile source is merged but live behavior is unproven. Keep delivery/Spam/DNS/template evidence under #119. An accepted request does not prove delivery, and a browser cooldown can reset on refresh/account change; it is not server abuse protection. Password/action-code recovery remains AUTH-MAIL-002C under #120. | +| RISK-040 | AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) merged as `46557c7`: account creation returns `accepted` or `unavailable` without exposing provider details. AUTH-MAIL-002B [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) merged as `23bca8c8`: My Account makes no false “sent” claim, blocks rapid repeats, and applies the same 60-second browser cooldown after either outcome. Its protected release run `29252492614` stopped before build because the required public App Check key was absent, so neither frontend revision is published. AUTH-MAIL-002C1 [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one byte-equivalent password-reset result after provider success or failure, an immediate repeat guard, and the same browser wait. Synthetic tests use mocked calls and canary values only. | Publish and verify each exact frontend revision separately. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, and private Firebase email-enumeration-protection readback under #119. Identical browser copy does not prove the direct Firebase API suppresses account-state differences. An accepted verification request does not prove delivery, and browser cooldowns can reset; they are not server abuse protection. Incoming action-code recovery remains AUTH-MAIL-002C2 under #120. | ## 5. Threat model diff --git a/docs/officers/EMERGENCY_AND_RECOVERY.md b/docs/officers/EMERGENCY_AND_RECOVERY.md index 706606cb..c6fa98cb 100644 --- a/docs/officers/EMERGENCY_AND_RECOVERY.md +++ b/docs/officers/EMERGENCY_AND_RECOVERY.md @@ -29,6 +29,7 @@ | Site unavailable or old after a merge | Ask the platform owner to check Netlify and GitHub Pages separately; do not change DNS. | | Login or member access is wrong | Stop role requests and contact the identity/platform owners. | | Account exists but a verification email request is unavailable | Ask the member to stop resending. Record only the time, route path `/login` or `/account`, and plain status. Omit all query/fragment text. Do not ask for the email address, password, code, action link, mailbox access, or screenshot. | +| Password reset does not lead to a private message | Ask the member to stop after one displayed wait and one retry. Record only the time, route path `/login`, and plain status. Do not ask which address they entered or whether an account exists. | | Profile Save says permission is missing | Ask the member to stop, sign out, and wait. Open a new redacted incident through [Request a change](./REQUEST_A_CHANGE.md). Do not edit the database, delete the login, recreate the account, or grant a role. | | Private member information is visible | Contact platform and privacy owners immediately; they choose containment through an approved service procedure. Do not change permissions yourself. | | Unexpected payment, refund, order, or signup | Contact treasurer and platform owners. The new source guard is not deployed and its officer control is NOT AVAILABLE YET. Do not test with real money. | @@ -113,6 +114,51 @@ After the exact #118 and #153 live proofs exist: **Escalation:** membership lead plus identity/platform owner; communications owner for delivery or Spam. +### Password reset request — NOT AVAILABLE YET + +**Status:** [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) is the tracked source change. It is not a live procedure until the exact revision is merged, published, and verified on `runmprc.com`. The protected release is also blocked on approved App Check and deployment configuration. Sender, Spam, delivery, and Firebase account-enumeration settings remain unverified owner work under [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). + +**Purpose:** give every member the same private reset instructions without revealing whether an account or message exists. + +**Approver:** membership lead plus identity/platform owner. The communications owner approves sender and Spam guidance. + +**Prerequisites:** exact #155 merge and website publication evidence; exact `runmprc.com` revision evidence; private #119 readback of sender and Firebase enumeration protection; and an approved safe email sink for any staged provider check. A source test, preview, green workflow, or real member mailbox is not enough. + +```mermaid +flowchart LR + Start["Member chooses Forgot password once"] --> Same["Page always shows the same private result"] + Same --> Wait["Wait through the visible 60 seconds"] + Wait --> Retry["Try once more if needed"] + Retry --> Stop["Stop and contact the identity owner"] +``` + +In words: make one request, show no account or provider result, wait one full minute, allow one retry, then stop. + +Officer steps after every prerequisite has live proof: + +1. Ask the member to choose **Forgot password?** once on their own device. +2. Do not ask which email address they entered. +3. Do not ask for a password, code, reset link, screenshot, or mailbox access. +4. Ask them to read only the plain result title. +5. Ask them to wait through the full visible 60-second countdown. +6. Ask them to check Inbox and Spam privately after a few minutes. +7. If a reset message is in Spam, ask them to mark it **Not spam**. +8. If no message arrives, ask them to choose the one retry after the countdown. +9. If the second attempt does not help, stop. +10. Open a redacted incident through [Request a change](./REQUEST_A_CHANGE.md). +11. Record only the time, `/login`, and `Password reset request finished`. +12. Contact the identity/platform owner and communications owner. + +**Expected result:** every attempted provider outcome has the same plain result, rapid repeats are blocked, the same 60-second wait follows, and no address or provider detail appears in the result. This does not prove an account exists, a message was sent, Firebase blocks direct enumeration, or delivery works. + +**Stop conditions:** more than one retry, refresh used to bypass the countdown, a real mailbox test, a request for an address or private link, different public results for different addresses, missing exact website evidence, or missing private #119 provider readback. + +**Success proof:** exact #155 pull request and merge commit; green synthetic no-network tests; website publication record; separate `runmprc.com` revision check; dated keyboard and screen-reader review; and private #119 evidence for sender, enumeration protection, and approved staged delivery. Keep each proof separate. + +**Undo:** publish and verify one reviewed frontend revert or safe roll-forward. Do not delete an account, edit Firebase Auth, or change an email template as a quick fix. + +**Escalation:** membership lead plus identity/platform owner; add the communications owner for Spam or delivery and the security owner if public results differ by account state. + ### Missing profile or profile permission error **Status:** automatic repair is **NOT AVAILABLE YET**. These are current stop-and-escalate steps only. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 3d66669a..f33dbdda 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -109,6 +109,8 @@ Officer steps after live proof: **Escalation:** membership lead plus identity/platform owner; add the communications owner for Spam or delivery problems. +Password reset is a separate recovery path. [#155](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/155) tracks one neutral result and one browser wait; it must never reuse the verification flow's `accepted` or `unavailable` account-specific wording. Until its exact website and private provider proofs exist, use only [Password reset request — NOT AVAILABLE YET](./EMERGENCY_AND_RECOVERY.md#password-reset-request--not-available-yet). Do not ask a member which address they entered. + ## Checkout adjustment guard — SOURCE ONLY, NOT LIVE **Purpose:** prevent an unknown discount, tax, or shipping charge from being treated as a valid payment. diff --git a/src/pages/login/LoginForm.css b/src/pages/login/LoginForm.css index b1b88ba1..e8f843ae 100644 --- a/src/pages/login/LoginForm.css +++ b/src/pages/login/LoginForm.css @@ -60,3 +60,73 @@ outline-offset: 2px; box-shadow: 0 0 0 3px #111827; } + +.password-reset-outcome { + margin-bottom: 1rem; + padding: 1rem; + color: #1e3a5f; + background: #eff6ff; + border: 2px solid #93c5fd; + border-radius: 0.5rem; + font-size: 0.95rem; + line-height: 1.55; +} + +.password-reset-outcome__result:focus { + outline: 3px solid #1d4ed8; + outline-offset: 3px; +} + +.password-reset-outcome--requesting { + font-weight: 700; +} + +.password-reset-outcome__title, +.password-reset-outcome__wait { + color: inherit; + font-weight: 700; +} + +.password-reset-outcome__message, +.password-reset-outcome__wait { + margin-top: 0.5rem; +} + +.password-reset-action { + min-height: 3rem; + padding: 0.65rem 0.8rem; + color: #005bd8; + background: transparent; + border: 2px solid transparent; + border-radius: 0.375rem; + font-weight: 700; + text-align: center; +} + +.password-reset-action:hover:not(:disabled) { + color: #fff; + background: #005bd8; +} + +.password-reset-action:focus-visible { + outline: 3px solid #1d4ed8; + outline-offset: 2px; +} + +.password-reset-action:disabled { + color: #4b5563; + cursor: not-allowed; +} + +.password-reset-ready-announcement { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/src/pages/login/LoginForm.jsx b/src/pages/login/LoginForm.jsx index 21fbcab9..6a1d187c 100644 --- a/src/pages/login/LoginForm.jsx +++ b/src/pages/login/LoginForm.jsx @@ -9,6 +9,8 @@ import SEO from '../../components/SEO'; import { getSafeLoginReturnPath } from './loginReturnPath'; import './LoginForm.css'; +const RESET_COOLDOWN_SECONDS = 60; + function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -17,20 +19,79 @@ function LoginForm() { const { services, isReady } = useServiceLocator(); const [registrationOutcome, setRegistrationOutcome] = useState(null); const [isRegistering, setIsRegistering] = useState(false); - const [resetSent, setResetSent] = useState(false); + const [resetState, setResetState] = useState('idle'); + const [resetSecondsRemaining, setResetSecondsRemaining] = useState(0); + const emailInputRef = useRef(null); const registrationStatusRef = useRef(null); + const resetStatusRef = useRef(null); + const resetInFlightRef = useRef(false); + const resetRetryAtRef = useRef(0); + const mountedRef = useRef(true); const location = useLocation(); const navigate = useNavigate(); + const resetIsRequesting = resetState === 'requesting'; + const resetIsCoolingDown = resetState === 'finished' && resetSecondsRemaining > 0; + const authActionBusy = isLoading || resetIsRequesting; let submitLabel = 'Sign in'; if (isLoading) submitLabel = 'Working...'; else if (isRegistering) submitLabel = 'Create account'; + let resetButtonLabel = 'Forgot password?'; + if (resetIsRequesting) resetButtonLabel = 'Requesting reset help...'; + else if (resetIsCoolingDown) { + resetButtonLabel = `Try again in ${resetSecondsRemaining} ${resetSecondsRemaining === 1 ? 'second' : 'seconds'}`; + } else if (resetState === 'finished') { + resetButtonLabel = 'Request password reset again'; + } + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + useEffect(() => { if (registrationOutcome) { registrationStatusRef.current?.focus(); } }, [registrationOutcome]); + useEffect(() => { + if (resetState === 'finished') { + resetStatusRef.current?.focus(); + } + }, [resetState]); + + useEffect(() => { + if (resetState !== 'finished') { + return undefined; + } + + const getRemaining = () => Math.max( + 0, + Math.ceil((resetRetryAtRef.current - Date.now()) / 1000), + ); + const initialRemaining = getRemaining(); + setResetSecondsRemaining(initialRemaining); + if (initialRemaining === 0) { + resetRetryAtRef.current = 0; + return undefined; + } + + let intervalId; + const updateCountdown = () => { + const remaining = getRemaining(); + setResetSecondsRemaining(remaining); + if (remaining === 0) { + resetRetryAtRef.current = 0; + window.clearInterval(intervalId); + } + }; + intervalId = window.setInterval(updateCountdown, 1000); + return () => window.clearInterval(intervalId); + }, [resetState]); + const handleSubmit = async (e) => { e.preventDefault(); setError(''); @@ -65,26 +126,40 @@ function LoginForm() { setIsRegistering(!isRegistering); setError(''); setRegistrationOutcome(null); - setResetSent(false); }; const handleForgotPassword = async () => { setError(''); - setResetSent(false); - if (!email) { + if (!email.trim()) { setError('Enter your email above first, then click "Forgot password?" again.'); + emailInputRef.current?.focus(); return; } if (!isReady || !services) { setError('Services not ready. Please try again.'); return; } + + const now = Date.now(); + if (resetInFlightRef.current || now < resetRetryAtRef.current) { + return; + } + + resetInFlightRef.current = true; + setResetState('requesting'); + setResetSecondsRemaining(0); try { - await services.identityService.sendPasswordReset(email); + await services.identityService.sendPasswordReset(email.trim()); } catch { // Intentionally swallow — we don't want to leak whether the email exists. + } finally { + if (mountedRef.current) { + resetRetryAtRef.current = Date.now() + (RESET_COOLDOWN_SECONDS * 1000); + setResetSecondsRemaining(RESET_COOLDOWN_SECONDS); + setResetState('finished'); + } + resetInFlightRef.current = false; } - setResetSent(true); }; return ( @@ -133,29 +208,82 @@ function LoginForm() { )} {error && ( -
+
{error}
)} - {resetSent && ( -
- If an account exists for that email, a password reset link is on its way. + {!isRegistering && resetState === 'requesting' && ( +
+ Finishing the password reset request... +
+ )} + + {!isRegistering && resetState === 'finished' && ( +
+
+

Password reset request finished.

+

+ For privacy, this page always shows the same result. Email delivery + cannot be confirmed. Wait a few minutes, then check Inbox and Spam. + If you find a reset message in Spam, mark it “Not spam.” Never share + a reset link or code. +

+
+

+ {resetSecondsRemaining > 0 + ? `You can try once more in ${resetSecondsRemaining} ${resetSecondsRemaining === 1 ? 'second' : 'seconds'}.` + : 'You can request password reset help again now.'} +

)} + {!isRegistering + && resetState === 'finished' + && resetSecondsRemaining === 0 && ( + + Password reset help is available again. + + )} + {!registrationOutcome && (
)}
diff --git a/src/pages/login/LoginForm.test.jsx b/src/pages/login/LoginForm.test.jsx index 56975538..03588ece 100644 --- a/src/pages/login/LoginForm.test.jsx +++ b/src/pages/login/LoginForm.test.jsx @@ -1,10 +1,14 @@ /* eslint-env jest */ import React from 'react'; import PropTypes from 'prop-types'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { MemoryRouter, Route, Routes, useLocation, } from 'react-router-dom'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { + act, fireEvent, render, screen, +} from '@testing-library/react'; import LoginForm from './LoginForm'; import AdminGuard from '../admin/AdminGuard'; import { useServiceLocator } from '../../services/ServiceLocatorContext'; @@ -97,6 +101,13 @@ function submitRegistration(email = 'member@example.test') { fireEvent.click(screen.getByRole('button', { name: 'Create account' })); } +function requestPasswordReset(email = 'synthetic-member@example.test') { + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: email }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); +} + describe('LoginForm return navigation', () => { const signIn = jest.fn(); const register = jest.fn(); @@ -104,11 +115,15 @@ describe('LoginForm return navigation', () => { beforeEach(() => { jest.clearAllMocks(); + signIn.mockReset(); + register.mockReset(); + sendPasswordReset.mockReset(); signIn.mockResolvedValue({ user: { email: 'member@example.com' } }); register.mockResolvedValue({ user: { email: 'member@example.test' }, verificationEmailRequest: 'accepted', }); + sendPasswordReset.mockResolvedValue(undefined); useServiceLocator.mockReturnValue({ isReady: true, services: { @@ -122,6 +137,11 @@ describe('LoginForm return navigation', () => { }); }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + test('round trips query and hash through an actual protected-route redirect', async () => { signIn.mockImplementation(async () => { useAuth.mockReturnValue({ @@ -270,4 +290,314 @@ describe('LoginForm return navigation', () => { const visibleText = document.body.textContent; providerCanaries.forEach((canary) => expect(visibleText).not.toContain(canary)); }); + + test.each(['', ' '])('requires a local email value without making a reset request', (email) => { + renderLogin({ + pathname: '/login', + state: { from: '/discounts?kind=race#active' }, + }); + jest.useFakeTimers(); + if (email) { + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: email }, + }); + } + + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Enter your email above first, then click "Forgot password?" again.', + ); + expect(screen.getByLabelText('Email address')).toHaveFocus(); + expect(sendPasswordReset).not.toHaveBeenCalled(); + expect(signIn).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + expect(screen.getByTestId('return-path')) + .toHaveTextContent('/discounts?kind=race#active'); + }); + + test('blocks rapid repeat activation before React can disable the reset action', async () => { + let finishRequest; + sendPasswordReset.mockImplementationOnce(() => new Promise((resolve) => { + finishRequest = resolve; + })); + renderLogin(); + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'synthetic-member@example.test' }, + }); + const button = screen.getByRole('button', { name: 'Forgot password?' }); + + await act(async () => { + fireEvent.click(button); + fireEvent.click(button); + }); + + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + expect(sendPasswordReset).toHaveBeenCalledWith('synthetic-member@example.test'); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent('Requesting reset help...'); + expect(screen.getByRole('status')).toHaveTextContent( + 'Finishing the password reset request...', + ); + expect(screen.getByLabelText('Email address')).toBeDisabled(); + expect(screen.getByLabelText('Password')).toBeDisabled(); + expect(signIn).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + + await act(async () => { + finishRequest(); + await Promise.resolve(); + }); + + expect(screen.getByRole('status')).toHaveTextContent( + 'Password reset request finished.', + ); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + }); + + test('renders byte-equivalent public state after provider success or hostile failure', async () => { + const capturePublicState = () => ({ + result: screen.getByRole('status').outerHTML, + wait: screen.getByText(/You can try once more in 60 seconds/).outerHTML, + button: screen.getByRole('button', { name: 'Try again in 60 seconds' }).outerHTML, + }); + const first = renderLogin(); + requestPasswordReset(); + await screen.findByText('Password reset request finished.'); + const successState = capturePublicState(); + first.unmount(); + + const consoleSpies = [ + jest.spyOn(console, 'log').mockImplementation(() => undefined), + jest.spyOn(console, 'warn').mockImplementation(() => undefined), + jest.spyOn(console, 'error').mockImplementation(() => undefined), + ]; + const canaries = [ + 'private-member@example.test', + 'synthetic-private-uid', + 'auth/provider-code-canary', + 'https://identity.example.test/action?code=reset-token-canary#private', + ]; + sendPasswordReset.mockRejectedValueOnce(Object.assign( + new Error(canaries.join(' ')), + { code: canaries[2], actionLink: canaries[3] }, + )); + renderLogin(); + requestPasswordReset('private-member@example.test'); + await screen.findByText('Password reset request finished.'); + const failureState = capturePublicState(); + + expect(failureState).toEqual(successState); + const result = screen.getByRole('status'); + expect(result).toHaveTextContent('this page always shows the same result'); + expect(result).toHaveTextContent('Email delivery cannot be confirmed'); + expect(result).toHaveTextContent('check Inbox and Spam'); + expect(result).toHaveAttribute('aria-live', 'polite'); + expect(result).toHaveAttribute('aria-atomic', 'true'); + expect(result).toHaveFocus(); + expect(result).not.toHaveTextContent( + /\bsent\b|\bdelivered\b|\baccepted\b|\bfailed\b|on its way|account exists|account missing/i, + ); + canaries.forEach((canary) => expect(document.body).not.toHaveTextContent(canary)); + expect(JSON.stringify(consoleSpies.flatMap((spy) => spy.mock.calls))) + .not.toMatch(/private-member|private-uid|provider-code|reset-token/i); + }); + + test('uses the same 60-second wait after failure and permits one later retry', async () => { + sendPasswordReset + .mockRejectedValueOnce(new Error('synthetic provider unavailable')) + .mockResolvedValueOnce(undefined); + renderLogin({ + pathname: '/login', + state: { from: '/discounts?kind=race#active' }, + }); + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'synthetic-member@example.test' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'synthetic-password' }, + }); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-13T13:30:00Z')); + + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + await act(async () => Promise.resolve()); + + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(screen.getByText('You can try once more in 60 seconds.')) + .toHaveAttribute('aria-live', 'off'); + expect(jest.getTimerCount()).toBe(1); + act(() => jest.advanceTimersByTime(59_000)); + expect(screen.getByRole('button', { name: 'Try again in 1 second' })) + .toBeDisabled(); + expect(screen.getByText('You can try once more in 1 second.')) + .toBeInTheDocument(); + act(() => jest.advanceTimersByTime(1_000)); + + const retry = screen.getByRole('button', { name: 'Request password reset again' }); + expect(retry).toBeEnabled(); + expect(screen.getByText('You can request password reset help again now.')) + .toBeInTheDocument(); + expect(screen.getByText('Password reset help is available again.')) + .toHaveAttribute('aria-live', 'polite'); + expect(jest.getTimerCount()).toBe(0); + fireEvent.click(retry, { detail: 0 }); + await act(async () => Promise.resolve()); + + expect(sendPasswordReset).toHaveBeenCalledTimes(2); + expect(sendPasswordReset).toHaveBeenNthCalledWith( + 2, + 'synthetic-member@example.test', + ); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(screen.getByLabelText('Email address')) + .toHaveValue('synthetic-member@example.test'); + expect(screen.getByLabelText('Password')).toHaveValue('synthetic-password'); + expect(screen.getByTestId('return-path')) + .toHaveTextContent('/discounts?kind=race#active'); + expect(signIn).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + }); + + test('cleans a completed countdown and returns to idle after remount', async () => { + const first = renderLogin(); + jest.useFakeTimers(); + requestPasswordReset(); + await act(async () => Promise.resolve()); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(jest.getTimerCount()).toBe(1); + + first.unmount(); + expect(jest.getTimerCount()).toBe(0); + renderLogin(); + + expect(screen.getByRole('button', { name: 'Forgot password?' })).toBeEnabled(); + expect(screen.queryByText('Password reset request finished.')).not.toBeInTheDocument(); + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + }); + + test('recovers when the deadline expires before the countdown effect starts', async () => { + renderLogin(); + jest.useFakeTimers(); + const nowSpy = jest.spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValue(61_000); + + requestPasswordReset(); + await act(async () => Promise.resolve()); + + expect(screen.getByRole('button', { name: 'Request password reset again' })) + .toBeEnabled(); + expect(screen.getByText('You can request password reset help again now.')) + .toBeInTheDocument(); + expect(screen.getByText('Password reset help is available again.')) + .toHaveClass('password-reset-ready-announcement'); + expect(jest.getTimerCount()).toBe(0); + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(0); + fireEvent.click(screen.getByRole('button', { name: 'Request password reset again' })); + await act(async () => Promise.resolve()); + expect(sendPasswordReset).toHaveBeenCalledTimes(2); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + }); + + test.each(['resolve', 'reject'])('discards a pending %s after unmount', async (outcome) => { + let finishRequest; + sendPasswordReset.mockImplementationOnce(() => new Promise((resolve, reject) => { + finishRequest = outcome === 'resolve' ? resolve : reject; + })); + const first = renderLogin(); + jest.useFakeTimers(); + requestPasswordReset(); + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + first.unmount(); + + await act(async () => { + finishRequest(new Error('post-unmount-private-canary')); + await Promise.resolve(); + }); + + expect(jest.getTimerCount()).toBe(0); + renderLogin(); + expect(screen.getByRole('button', { name: 'Forgot password?' })).toBeEnabled(); + expect(screen.queryByText('Password reset request finished.')).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('post-unmount-private-canary'); + }); + + test('preserves route, inputs, mode, and cooldown without unrelated identity calls', async () => { + renderLogin({ + pathname: '/login', + state: { from: '/discounts?kind=race#active' }, + }); + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'synthetic-member@example.test' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'synthetic-password' }, + }); + jest.useFakeTimers(); + fireEvent.click(screen.getByRole('button', { name: 'Forgot password?' })); + await act(async () => Promise.resolve()); + + expect(screen.getByTestId('location')).toHaveTextContent('/login'); + expect(screen.getByTestId('return-path')) + .toHaveTextContent('/discounts?kind=race#active'); + expect(screen.getByLabelText('Email address')) + .toHaveValue('synthetic-member@example.test'); + expect(screen.getByLabelText('Password')).toHaveValue('synthetic-password'); + fireEvent.click(screen.getByRole('button', { name: 'New here? Register' })); + expect(screen.getByRole('heading', { name: 'Create your account' })).toBeInTheDocument(); + expect(screen.queryByText('Password reset request finished.')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Have an account? Sign in' })); + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument(); + expect(screen.getByText('Password reset request finished.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + expect(signIn).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + }); + + test('uses a native keyboard action and scoped 48px focus-visible styling', async () => { + renderLogin(); + fireEvent.change(screen.getByLabelText('Email address'), { + target: { value: 'synthetic-member@example.test' }, + }); + const button = screen.getByRole('button', { name: 'Forgot password?' }); + expect(button).toHaveAttribute('type', 'button'); + button.focus(); + expect(button).toHaveFocus(); + + fireEvent.click(button, { detail: 0 }); + await screen.findByText('Password reset request finished.'); + + const result = screen.getByRole('status'); + expect(result).toHaveFocus(); + expect(result).toHaveAttribute('tabindex', '-1'); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toHaveAttribute( + 'aria-describedby', + 'password-reset-status password-reset-wait', + ); + expect(sendPasswordReset).toHaveBeenCalledTimes(1); + expect(signIn).not.toHaveBeenCalled(); + expect(register).not.toHaveBeenCalled(); + + const css = readFileSync(join(__dirname, 'LoginForm.css'), 'utf8'); + expect(css).toMatch(/\.password-reset-action\s*\{[\s\S]*min-height:\s*3rem;/); + expect(css).toMatch(/\.password-reset-action:focus-visible\s*\{[\s\S]*outline:/); + expect(css).toMatch( + /\.password-reset-ready-announcement\s*\{[\s\S]*position:\s*absolute;[\s\S]*clip-path:\s*inset\(50%\);/, + ); + }); });