diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 48426968..05c8c547 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -328,6 +328,8 @@ 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. + ### Scope - Reject member/admin/capability grants when the target Firebase user's email is unverified. diff --git a/GITHUB_ISSUE_SLICES.md b/GITHUB_ISSUE_SLICES.md index 69e619e6..691b051b 100644 --- a/GITHUB_ISSUE_SLICES.md +++ b/GITHUB_ISSUE_SLICES.md @@ -36,6 +36,9 @@ An agent-ready child has one observable outcome, one trust boundary, explicit de | AUTH-003B | proposed; AUTH-003A | Apply capabilities to server functions and Firestore projections/rules. | Every capability/action pair has allow/deny tests. | | 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. | | 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 957e21bc..80c228cc 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -578,7 +578,7 @@ Required release order: If the website arrives before `ensureMemberProfile` or the matching Rules, the intended safe result is a temporary-unavailable message with Edit hidden. Roll back the website or safely roll forward the reviewed backend. Never release the callable while the old signup trigger remains: that trigger can overwrite a recovered profile or replace claims. Deploy the two revised Functions together, or stop and roll back the partial backend release. Do not create, delete, overwrite, or re-role a production account in Firebase Console. -### Registration email outcome — SOURCE ONLY, NOT LIVE +### Account verification email outcomes — SOURCE ONLY, NOT LIVE AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) separates three transitions: @@ -586,7 +586,9 @@ AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) 2. Account creation succeeds and Firebase accepts the verification request: preserve the signed-in account and report `accepted`; do not claim delivery. 3. Account creation succeeds and Firebase rejects the verification request: preserve the signed-in account, report `unavailable`, emit only the fixed `email_verification_failed` diagnostic, and offer My Account as an inspection path. If My Account is unavailable, stop and keep the account. Do not advertise the existing resend action as proven. -The source and tests do not change Firebase Auth, email templates, DNS, sender branding, or mailbox/provider settings. They do not send a network request in tests. Do not test this flow with a production mailbox or a real member. +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. Release and evidence order: @@ -594,11 +596,11 @@ Release and evidence order: 2. Merge the exact reviewed commit. 3. Publish that frontend revision only through the approved website release path. 4. Verify the hosting record and the exact `runmprc.com` revision separately. -5. Before calling the linked recovery usable, verify the exact [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) Rules, Functions, and profile page plus #120's truthful resend result/cooldown child. An unavailable My Account page is a stop result. +5. Before calling the linked recovery usable, verify the exact [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) Rules, Functions, and profile page plus [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153)'s truthful resend result/cooldown. An unavailable My Account page is a stop result. 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 profile-independent resend/cooldown/session recovery and password/action-code recovery under parent [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120). The current Resend success message is not delivery proof. +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. Rollback is a reviewed frontend revert or safe roll-forward. Do not delete or recreate an Auth account because the verification request failed. @@ -624,4 +626,6 @@ For OBS-001A3 [#142](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/142), For AUTH-MAIL-002A [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145), **source/tests** means mocked account-create and email-request outcomes plus fixed-output canary checks; **merge** means the resulting `main` SHA; **website publication** and **`runmprc.com` revision verification** are separate; **Firebase/provider configuration** is unchanged; and **live delivery** remains unverified. An `accepted` result proves only that Firebase accepted the request. It does not prove Inbox delivery, sender reputation, or Spam placement. +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. + 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 b78249b7..42b2d48c 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) source returns `accepted` or `unavailable` after account creation, keeps the signed-in credential on request failure, and shows a focused generic status with one My Account inspection/stop path. Synthetic tests use mocked Firebase calls and canary values only. | Merge and publish the exact frontend revision, verify `runmprc.com` separately, and keep provider delivery/Spam/DNS/template evidence under #119. A source result or accepted request does not prove delivery. Do not call My Account recovery usable until #118 is live and #120's resend result/cooldown child is complete; password/action-code recovery also remains open 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, 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. | ## 5. Threat model diff --git a/docs/officers/EMERGENCY_AND_RECOVERY.md b/docs/officers/EMERGENCY_AND_RECOVERY.md index 4fa35d15..706606cb 100644 --- a/docs/officers/EMERGENCY_AND_RECOVERY.md +++ b/docs/officers/EMERGENCY_AND_RECOVERY.md @@ -28,7 +28,7 @@ | Wrong public text, image, or link | Ask the platform owner for one reviewed revert pull request. | | 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 verification email request failed | Ask the member to stop resending. Record only the time, public `/login` URL, and plain status. Do not ask for the email address, password, code, action link, mailbox access, or screenshot. | +| 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. | | 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. | @@ -75,30 +75,39 @@ In words: stop, record only safe public facts, contact both owners, and wait for **Escalation:** treasurer plus platform owner; add the security owner for an unauthorized command and the privacy owner if member information appeared. -### Account created but verification request failed +### Verification email request unavailable -**Status:** the truthful two-result screen in [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) is **SOURCE ONLY, NOT LIVE** until its exact website revision is published and verified. +**Status:** the truthful create-account result in [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) and My Account resend/countdown in [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) are **SOURCE ONLY, NOT LIVE** until the exact website revision is published and verified. #118 profile source is merged, but its backend and live behavior are also unproven. **Purpose:** preserve the new account and prevent repeated email requests or unsafe account repair. **Approver:** membership lead plus identity/platform owner. -**Prerequisites:** the time, the public `/login` address, and the exact plain status with all private details omitted. My Account recovery is not proven until [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) records the exact Rules, Functions, and profile page, and [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120) records a truthful resend result plus cooldown. Without both proofs, use only these stop-and-escalate steps. +**Prerequisites:** the time, the public route path `/login` or `/account`, and the exact plain status with all private details omitted. Do not copy anything after `?` or `#` in the browser address. My Account recovery is not proven until [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) has exact Rules/Functions/profile-page deployment evidence and [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) has exact website evidence. Without both proofs, use only the stop-and-escalate steps below. -1. Ask the member to stop clicking Create account or Resend. +1. Ask the member to stop clicking Create account or the email-request action. 2. Do not ask for their email address, password, code, action link, mailbox access, or screenshot. -3. Record only `account created; verification request did not finish`, the time, and the public `/login` URL. +3. Record only the exact plain `accepted` or `unavailable` status, the time, and the route path `/login` or `/account`. Omit all query text after `?` and all fragment text after `#`. 4. Ask the member to keep the account. Do not delete or recreate it. 5. Open a redacted incident through [Request a change](./REQUEST_A_CHANGE.md). 6. Contact the identity/platform owner. 7. Add the communications owner if accepted messages land in Spam or never arrive. 8. Wait for separate website and provider evidence before saying the problem is fixed. -**Expected result:** no private data is copied, no duplicate account is created, and the owner can separate a website request failure from a provider-delivery problem. +After the exact #118 and #153 live proofs exist: -**Stop conditions:** repeated resend attempts, real mailbox tests, direct Firebase Console changes, account deletion/recreation, role changes, or a request for private details. +1. Ask the member to choose **Request another verification email** once. +2. Ask them to wait through the full visible 60-second countdown. +3. If accepted, ask them to check Inbox and Spam once. Accepted does not mean delivered. +4. If unavailable, wait through the countdown and try once more. +5. If the second request is unavailable, stop and escalate. +6. Do not refresh to bypass the browser countdown. It can reset, but Firebase can still throttle requests. -**Success proof:** the exact website revision displays the correct generic status in an approved synthetic check. Sender branding, delivery, and Spam placement need separate private evidence under [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). +**Expected result:** no private data is copied, no duplicate account is created, rapid repeats are blocked, and the owner can separate a website request failure from a provider-delivery problem. + +**Stop conditions:** more than one retry after the countdown, refresh used to bypass it, real mailbox tests, direct Firebase Console changes, account deletion/recreation, role changes, or a request for private details. + +**Success proof:** the exact website revision displays accepted/unavailable outcomes, the visible countdown, and one later retry in an approved synthetic check. Sender branding, delivery, and Spam placement need separate private evidence under [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). **Undo:** ask for one reviewed frontend revert or safe roll-forward. Never undo this problem by deleting the login account. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index b1a439ee..3d66669a 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -61,7 +61,7 @@ If any proof is missing, report the change as **not live**. **Approver:** membership lead plus identity/platform owner. -**Prerequisites:** issue [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) is merged; the exact website revision is published and verified; and the identity owner confirms the plain status text. Before calling My Account a working recovery path, also verify the profile Rules, Functions, and page from [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) plus the resend truth/cooldown child under [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120). Email sender and Spam-folder improvements remain separate owner work in [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). +**Prerequisites:** issue [#145](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/145) is merged. Before publishing the #153 website revision, verify that the exact [#118](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/118) Rules and Functions were deployed and read back. After publishing, verify the matching profile page and the resend result/countdown from [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) before calling My Account a working recovery path. The identity owner must confirm the plain status text. Source or merge evidence alone is not live proof. Email sender and Spam-folder improvements remain separate owner work in [#119](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/119). ```mermaid flowchart TD @@ -70,10 +70,16 @@ flowchart TD B -- "Yes" --> D["Show Account created"] D --> E{"Did the email service accept the request?"} E -- "Yes" --> F["Say accepted; check Inbox and Spam"] - E -- "No" --> G["Keep account; check My Account; stop if unavailable"] + E -- "No" --> G["Keep account; open My Account"] + G --> H["Choose Request once"] + H --> I{"Was the request accepted?"} + I -- "Yes" --> K["Wait through the same 60-second countdown"] + I -- "No" --> K + K -- "Accepted result" --> J["Check Inbox and Spam once"] + K -- "Unavailable result" --> L["Try once more, then stop and escalate"] ``` -In words: account creation and the later email request are two separate results. Neither result proves that an email reached the Inbox. +In words: account creation and each later email request are separate results. Accepted does not mean delivered. The same 60-second browser wait follows either resend result. Refreshing the page or changing accounts can reset that display, so it is not a server safety limit. Until the prerequisites are proven, the current website message may still be wrong. Do not treat it as delivery evidence. @@ -85,14 +91,19 @@ Officer steps after live proof: 4. If the message is in Spam, ask them to mark it **Not spam**. 5. If the status says the request did not finish, ask them to choose **Check My Account**. 6. If My Account is unavailable, stop. Keep the account and open a redacted incident through [Request a change](./REQUEST_A_CHANGE.md). -7. Do not ask the member to use the existing Resend button yet. A truthful resend result, cooldown, and session recovery are **NOT AVAILABLE YET** under parent issue [#120](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/120). -8. Stop repeated create-account or resend attempts. +7. Use the next steps only after the exact [#153](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/153) website revision is published and verified. Until then, stop and escalate. +8. Ask the member to choose **Request another verification email** once. +9. Ask them to wait through the full visible 60-second countdown. +10. If the page says the request was accepted, ask them to check Inbox and Spam once. Do not promise delivery. +11. If the page says the request was unavailable, wait for the countdown and try once more. +12. If that second request is unavailable, stop. Open a redacted incident through [Request a change](./REQUEST_A_CHANGE.md). +13. Do not refresh to bypass the display, create another account, or keep clicking. Firebase can still throttle a reset browser countdown. -**Expected result:** the page says `Account created` only after creation succeeds. It separately says the email request was accepted or did not finish. It never displays the member's address or the provider's error. An unavailable My Account page is a stop-and-escalate result, not proof that the account failed. +**Expected result:** the page says `Account created` only after creation succeeds. It separately says an email request was accepted or unavailable. My Account disables the request action for 60 visible seconds after either result. The request-result message never repeats the member's address or the provider's error. An unavailable My Account page is a stop-and-escalate result, not proof that the account failed. -**Stop conditions:** a request for private account details, repeated resends, a production email test, a claim that accepted means delivered, or a website revision that cannot be identified. +**Stop conditions:** a request for private account details, more than one retry after the countdown, a production email test, refreshing to bypass the countdown, a claim that accepted means delivered, or a website revision that cannot be identified. -**Success proof:** exact pull request and merge commit, green synthetic tests, website publication record, separate `runmprc.com` revision check, and dated plain-text review. Claim My Account recovery only after #118's exact backend/profile page and #120's truthful resend/cooldown child are verified together. Provider delivery, sender branding, Spam placement, and a real mailbox remain unproven unless #119 records owner-approved private evidence. +**Success proof:** exact pull requests and merge commits for #145, #118, and #153; green synthetic tests; exact #118 Rules and Function deployment/readback before the website; a made-up profile-page check; website publication record; separate `runmprc.com` revision check; and dated plain-text review. Provider delivery, sender branding, Spam placement, and a real mailbox remain unproven unless #119 records owner-approved private evidence. **Undo:** publish and verify one reviewed frontend revert or safe roll-forward. Do not delete or recreate the Firebase account. diff --git a/src/pages/account/Account.css b/src/pages/account/Account.css new file mode 100644 index 00000000..17505c8f --- /dev/null +++ b/src/pages/account/Account.css @@ -0,0 +1,91 @@ +.account-verification-notice { + display: flex; + margin-top: 0.75rem; + padding: 1rem; + flex-direction: column; + gap: 0.75rem; + color: #713f12; + background: #fef3c7; + border: 1px solid #f59e0b; + border-radius: 0.5rem; + font-size: 0.95rem; + line-height: 1.5; +} + +.verification-resend { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; +} + +.verification-resend__result, +.verification-resend__countdown { + margin: 0; +} + +.verification-resend__result { + padding: 0.75rem; + border: 2px solid; + border-radius: 0.375rem; +} + +.verification-resend__result--sending { + color: #1e3a8a; + background: #eff6ff; + border-color: #93c5fd; +} + +.verification-resend__result--accepted { + color: #14532d; + background: #f0fdf4; + border-color: #86efac; +} + +.verification-resend__result--unavailable { + color: #713f12; + background: #fefce8; + border-color: #facc15; +} + +.verification-resend__countdown { + color: #4b5563; + font-size: 0.875rem; +} + +.verification-resend__button { + min-height: 3rem; + padding: 0.7rem 1rem; + color: #fff; + background: #005bd8; + border: 2px solid #005bd8; + border-radius: 0.375rem; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.verification-resend__button:hover:not(:disabled) { + color: #111827; + background: #facc15; + border-color: #111827; +} + +.verification-resend__button:focus-visible { + outline: 3px solid #fff; + outline-offset: 2px; + box-shadow: 0 0 0 5px #111827; +} + +.verification-resend__button:disabled { + color: #374151; + background: #e5e7eb; + border-color: #9ca3af; + cursor: not-allowed; +} + +@media (min-width: 640px) { + .account-verification-notice { + align-items: flex-start; + } +} diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index 60e813d1..13d5d3b0 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -1,8 +1,10 @@ /* eslint-env jest */ import React from 'react'; +import { readFileSync } from 'fs'; +import { join } from 'path'; import { - fireEvent, render, screen, waitFor, + act, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { useServiceLocator } from '../../services/ServiceLocatorContext'; @@ -59,22 +61,27 @@ const PROFILE = { const app = { name: 'synthetic-app' }; const firestore = { name: 'synthetic-firestore' }; const signOut = jest.fn(); +const resendVerificationEmail = jest.fn(); -function renderAccount() { - return render( +function accountView(user = USER) { + return ( - - , + + ); } +function renderAccount(user = USER) { + return render(accountView(user)); +} + describe('Account profile recovery', () => { beforeEach(() => { jest.clearAllMocks(); (useServiceLocator as jest.Mock).mockReturnValue({ services: { firebaseResources: { app, firestore }, - identityService: { signOut, resendVerificationEmail: jest.fn() }, + identityService: { signOut, resendVerificationEmail }, }, isReady: true, }); @@ -85,6 +92,12 @@ describe('Account profile recovery', () => { events: {}, }); (updateMyProfile as jest.Mock).mockResolvedValue(undefined); + resendVerificationEmail.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); }); test('ensures the caller profile before the first profile read', async () => { @@ -224,4 +237,184 @@ describe('Account profile recovery', () => { expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument(); expect(screen.queryByTestId('strava-section')).not.toBeInTheDocument(); }); + + test('reports request acceptance without claiming delivery and blocks rapid repeats', async () => { + let finishRequest: (() => void) | undefined; + resendVerificationEmail.mockImplementationOnce(() => new Promise((resolve) => { + finishRequest = resolve; + })); + renderAccount(); + + const button = await screen.findByRole('button', { + name: 'Request another verification email', + }); + await act(async () => { + fireEvent.click(button); + fireEvent.click(button); + }); + + expect(resendVerificationEmail).toHaveBeenCalledTimes(1); + expect(button).toBeDisabled(); + expect(button).toHaveTextContent('Requesting...'); + expect(screen.getByRole('status')).toHaveTextContent( + 'Requesting a verification email...', + ); + + await act(async () => finishRequest?.()); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent('The request was accepted.'); + expect(status).toHaveTextContent('Delivery can take time.'); + expect(status).toHaveAttribute('aria-live', 'polite'); + expect(status).toHaveAttribute('aria-atomic', 'true'); + expect(status).not.toHaveTextContent(/\bsent\b|\bdelivered\b/i); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(updateMyProfile).not.toHaveBeenCalled(); + expect(signOut).not.toHaveBeenCalled(); + }); + + test('shows one fixed failure result without provider details', async () => { + const consoleSpies = [ + jest.spyOn(console, 'log').mockImplementation(() => undefined), + jest.spyOn(console, 'warn').mockImplementation(() => undefined), + jest.spyOn(console, 'error').mockImplementation(() => undefined), + ]; + resendVerificationEmail.mockRejectedValueOnce(Object.assign( + new Error('provider-canary member@example.test'), + { + code: 'auth/provider-canary', + actionLink: 'https://identity.example.test/action?code=secret-canary', + }, + )); + renderAccount(); + + fireEvent.click(await screen.findByRole('button', { + name: 'Request another verification email', + })); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('We could not request an email right now.'); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + expect(alert).toHaveAttribute('aria-atomic', 'true'); + expect(document.body).not.toHaveTextContent(/provider-canary|member@example\.test|secret-canary/i); + expect(JSON.stringify(consoleSpies.flatMap((spy) => spy.mock.calls))) + .not.toMatch(/provider-canary|member@example\.test|secret-canary/i); + expect(resendVerificationEmail).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + expect(updateMyProfile).not.toHaveBeenCalled(); + consoleSpies.forEach((spy) => spy.mockRestore()); + }); + + test('restores one retry after the visible cooldown', async () => { + resendVerificationEmail + .mockRejectedValueOnce(new Error('synthetic provider unavailable')) + .mockResolvedValueOnce(undefined); + renderAccount(); + const button = await screen.findByRole('button', { + name: 'Request another verification email', + }); + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-13T12:00:00Z')); + + fireEvent.click(button); + await act(async () => Promise.resolve()); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'We could not request an email right now.', + ); + expect(screen.getByRole('button', { name: 'Try again in 60 seconds' })) + .toBeDisabled(); + act(() => jest.advanceTimersByTime(59_000)); + expect(screen.getByRole('button', { name: 'Try again in 1 second' })) + .toBeDisabled(); + expect(screen.getByText('Another request is available in 1 second.')) + .toBeInTheDocument(); + act(() => jest.advanceTimersByTime(1_000)); + + const retry = screen.getByRole('button', { + name: 'Request another verification email', + }); + expect(retry).toBeEnabled(); + fireEvent.click(retry); + await act(async () => Promise.resolve()); + expect(resendVerificationEmail).toHaveBeenCalledTimes(2); + expect(screen.getByRole('status')).toHaveTextContent('The request was accepted.'); + }); + + test('a remount resets the browser-only cooldown without making another request', async () => { + const first = renderAccount(); + jest.useFakeTimers(); + fireEvent.click(await screen.findByRole('button', { + name: 'Request another verification email', + })); + await act(async () => Promise.resolve()); + expect(await screen.findByText('The request was accepted. Delivery can take time. Check Inbox and Spam.')) + .toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Try again in/ })).toBeDisabled(); + expect(jest.getTimerCount()).toBe(1); + + first.unmount(); + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + renderAccount(); + + expect(await screen.findByRole('button', { + name: 'Request another verification email', + })).toBeEnabled(); + expect(screen.queryByText(/The request was accepted\./i)).not.toBeInTheDocument(); + expect(resendVerificationEmail).toHaveBeenCalledTimes(1); + }); + + test('a UID change discards an in-flight result instead of showing it to another user', async () => { + let finishRequest: (() => void) | undefined; + resendVerificationEmail.mockImplementationOnce(() => new Promise((resolve) => { + finishRequest = resolve; + })); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { + name: 'Request another verification email', + })); + + const otherUser = { + uid: 'other-synthetic-user', + email: 'other-member@example.test', + role: 'unverified' as const, + }; + (getMyProfile as jest.Mock).mockImplementation(async (_firestore, uid) => ({ + ...PROFILE, + uid, + email: uid === otherUser.uid ? otherUser.email : PROFILE.email, + })); + view.rerender(accountView(otherUser)); + await act(async () => finishRequest?.()); + + expect(await screen.findByRole('button', { + name: 'Request another verification email', + })).toBeEnabled(); + expect(screen.queryByText(/The request was accepted\./i)).not.toBeInTheDocument(); + expect(resendVerificationEmail).toHaveBeenCalledTimes(1); + expect(updateMyProfile).not.toHaveBeenCalled(); + }); + + test('uses a focusable native button and a 48px focus-visible CSS target', async () => { + renderAccount(); + const button = await screen.findByRole('button', { + name: 'Request another verification email', + }); + + expect(button).toHaveAttribute('type', 'button'); + button.focus(); + expect(button).toHaveFocus(); + fireEvent.click(button, { detail: 0 }); + await act(async () => Promise.resolve()); + expect(resendVerificationEmail).toHaveBeenCalledTimes(1); + expect(updateMyProfile).not.toHaveBeenCalled(); + expect(signOut).not.toHaveBeenCalled(); + + const css = readFileSync(join(__dirname, 'Account.css'), 'utf8'); + expect(css).toMatch(/\.verification-resend__button\s*\{[\s\S]*min-height:\s*3rem;/); + expect(css).toMatch(/\.verification-resend__button:focus-visible\s*\{[\s\S]*outline:/); + }); }); diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index 51cdaf5e..2faea21b 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React, { + useEffect, useRef, useState, +} from 'react'; import { Link, Navigate, useLocation } from 'react-router-dom'; import { Timestamp } from 'firebase/firestore'; import SEO from '../../components/SEO'; @@ -17,6 +19,7 @@ import { import { formatEventDate, formatPrice } from '../../services/events/eventsService'; import StravaSection from './StravaSection'; import { getLocationReturnPath } from '../login/loginReturnPath'; +import './Account.css'; function roleLabel(role: string) { if (role === 'admin') return 'Admin'; @@ -31,36 +34,139 @@ function tsToDate(ts: Timestamp | null | undefined) { return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } +const VERIFICATION_RESEND_COOLDOWN_SECONDS = 60; + function ResendVerificationButton() { const { services } = useServiceLocator(); - const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + const [state, setState] = useState< + 'idle' | 'sending' | 'accepted' | 'unavailable' + >('idle'); + const [retryAt, setRetryAt] = useState(null); + const [remainingSeconds, setRemainingSeconds] = useState(0); + const inFlightRef = useRef(false); + const retryAtRef = useRef(0); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + if (!retryAt) return undefined; + const deadline = retryAt; + + function updateCountdown() { + const seconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1000)); + setRemainingSeconds(seconds); + if (seconds === 0) { + retryAtRef.current = 0; + setRetryAt(null); + } + } + + updateCountdown(); + const timer = window.setInterval(updateCountdown, 1000); + return () => window.clearInterval(timer); + }, [retryAt]); async function handleClick() { - if (!services) return; + if ( + !services + || inFlightRef.current + || retryAtRef.current > Date.now() + ) return; + + inFlightRef.current = true; setState('sending'); try { await services.identityService.resendVerificationEmail(); - setState('sent'); + if (mountedRef.current) setState('accepted'); } catch { - setState('error'); + if (mountedRef.current) setState('unavailable'); + } finally { + const nextRetryAt = Date.now() + VERIFICATION_RESEND_COOLDOWN_SECONDS * 1000; + retryAtRef.current = nextRetryAt; + inFlightRef.current = false; + if (mountedRef.current) { + setRemainingSeconds(VERIFICATION_RESEND_COOLDOWN_SECONDS); + setRetryAt(nextRetryAt); + } } } - if (state === 'sent') { - return Verification email sent.; - } - if (state === 'error') { - return Failed to send — try again later.; + const coolingDown = remainingSeconds > 0; + let buttonLabel = 'Request another verification email'; + const countdownUnit = remainingSeconds === 1 ? 'second' : 'seconds'; + if (state === 'sending') { + buttonLabel = 'Requesting...'; + } else if (coolingDown) { + buttonLabel = `Try again in ${remainingSeconds} ${countdownUnit}`; } + return ( - +
+ {state === 'sending' && ( +

+ Requesting a verification email... +

+ )} + {state === 'accepted' && ( +

+ The request was accepted. Delivery can take time. Check Inbox and Spam. +

+ )} + {state === 'unavailable' && ( + + )} + {coolingDown && ( +

+ Another request is available in + {' '} + {remainingSeconds} + {' '} + {countdownUnit} + . +

+ )} + +
); } @@ -403,9 +509,9 @@ export function AccountContent({ )} {profile && !profile.emailVerified && ( -
+
Your email address is unverified. - +
)} {profile?.role === 'unverified' && (