diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 387461d..1652aff 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -169,6 +169,8 @@ Exit gate: - Payment SLOs, structured redacted logs, and actionable alerts are live. - Backup restoration into isolated infrastructure succeeds and is documented. +**AUTH-006G current source boundary:** [#651](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/651) changes only confirmed full-name Save feedback and keyboard-focus settlement in My Account. An exact current update followed by its current successful non-null authoritative profile reread renders exactly **Profile name saved.** between the Profile heading and **Edit**, with status, polite live, atomic, programmatic-focus, 320-pixel containment, and scoped visible-outline semantics. The two-column header grid keeps heading and Edit on the first row while the result spans the second; DOM order remains heading, result, Edit, so Tab from the result reaches Edit. A pending focus intent is created only after validation and synchronous one-attempt admission, only while the exact connected Save button owns focus, and stores only the opaque current profile generation and attempt ID. Exact current confirmed success transfers the matching intent. One layout effect consumes it before target checks, leaves retained result focus alone, restores absent, body, document-root, or disconnected focus to the connected result, and preserves every other connected focus deliberately chosen while the save is pending. An unfocused or programmatic valid Save still shows the truthful result without moving focus. Initial load, validation failure, update rejection, missing or rejected confirmation read, reload, application/Firestore/identity/UID or generation change, newer attempt, stale completion, unmount, and later rerender cannot show or focus stale success; Edit, a new admitted Save, profile load, and context change clear the result and obsolete intents. Existing validation, write, reread, one-attempt/context fences, unconfirmed-change recovery, and exact service-call counts remain unchanged. The result and focus add no read, write, request, retry, provider call, log, or stored value. Failure/retry focus, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain separate. #651 adds one visible page-structure node but changes no data movement, permission, ownership, service contract, Function, Rule, schema, index, package, workflow, provider, account, sign-in, production data, deployment, publication, membership, dues, role, payment, entitlement, roster, biometric processing, or live behavior. The #118 backend-first profile-repair evidence remains separate. Directory availability stays `false`, live #623 remains inert, and #507 keeps every optional-directory connection and live-proof gate. + **Current optional-directory boundary:** Parent [#504](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/504) defines a private, opt-in officer people finder as name search with voluntary thumbnails—not facial recognition. MEMBERS-DIRECTORY-001A [#505](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/505) owns the signed-in person's server-only processed thumbnail and independent default-off preference. MEMBERS-DIRECTORY-001B [#506](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/506) adds source for the minimum server-only projection, current-state reconciler, bounded verified-admin name-prefix callable, query-free audit, and separate `/admin/member-directory` gallery. It returns at most 24 current opted-in display-name/optional-thumbnail cards and has no image query, facial recognition, cursor, total, export, or membership authority. MEMBERS-DIRECTORY-001D [#621](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/621) makes the shipped frontend boundary default unavailable: My Account and the guarded officer route show only visibly described, disabled layouts and perform zero directory reads, uploads, searches, saves, request-ID creation, or service calls. #623 published exactly that inert interface as deploy `6a7e072f8f346b0008510d29`; the temporary authority is re-paused. Protected layout proof remains synthetic. Signed-out public proof is limited to revision and guard readback plus the absence of a directory request. [#507](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/507) still owns #110 policy completion, scoped authorization, #133 protected authority, isolated staging, required backend/index deployment and readback, and a later separately reviewed source flip before connected website publication and live proof. Do not reuse the current browser-side full-account filter or describe the published disabled preview or #505/#506 source as an available directory. **MEMBERS-DIRECTORY-001E current source boundary:** [#627](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/627) hardens only the preserved connected frontend. New opt-in uses the projection's exact bounded Unicode display-name eligibility; an existing opt-in remains removable after the name becomes ineligible. Profile placeholders and control-linked generic errors, explicit search validation state, a non-counting successful-search announcement, scoped explicit contrast, keyboard/touch geometry, 320-pixel containment, and stale file-read fencing improve accessibility and race behavior without adding a data path. The availability value remains `false`, so the default source branch remains inert; the live #623 preview remains unchanged. This issue changes no Functions, Rules, indexes, service contract, package, workflow, release control, provider, account, sign-in, or production data. #507 still owns connection, privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. diff --git a/SECURITY.md b/SECURITY.md index ca66ea2..e52977a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -168,6 +168,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | DATA-001A9 / registration-sort failure containment | DATA-001A9 [#598](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/598) catches without binding every exceptional created-time comparison outcome in the final registration sort after event work completes and returns only fixed `unavailable / Registration data could not be loaded.` with no partial callable response. Synthetic `_seconds` access and numeric-coercion failures prove no caught failure-value inspection, formatting, serialization, logging, or raw escape while App Check, Auth, the exact-UID query, runner minimization, A5–A8 boundaries, event lookup concurrency, successful and empty behavior, and descending registration-created ordering stay unchanged. | This source is not deployed or live. It does not validate malformed-but-nonthrowing timestamps or event IDs; alter fallback-to-zero or tie behavior; contain event-ID derivation or response/platform serialization; cancel or reorder already completed event reads; change stored data, response shape, permissions, or retries; prove platform logging outside application code; or prove live behavior. Complete a protected Function deployment/readback and made-up account check before describing the fixed boundary as live. | | 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. | +| AUTH-006G / source-only confirmed-save interface containment for RISK-039 | [#651](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/651) gives an exact current full-name update plus successful non-null authoritative profile reread one fixed **Profile name saved.** result with status, polite live, atomic, programmatic-focus, bounded-layout, and scoped visible-outline semantics. Profile-header DOM order is heading, result, Edit. Separate pending and result tokens retain only the opaque profile generation and attempt ID. A token is armed only after validation and synchronous save admission while the exact connected Save button owns focus, and exact current confirmed success alone transfers it. One layout effect consumes the result token before target checks, requires the matching current generation and attempt plus the connected result, leaves retained result focus alone, restores absent, body, document-root, or disconnected focus, and preserves every other connected focus. Unfocused or programmatic success shows the result without moving focus. Initial load, validation failure, update rejection, missing or rejected confirmation read, reload, application/Firestore/identity/UID or generation change, newer attempt, stale work, unmount, and later rerender cannot show or focus stale success. Edit, a new admitted Save, profile load, and context change clear the result and obsolete tokens. Synthetic tests use made-up profiles and cover exact call counts, current reread projection, DOM/live/CSS semantics, lost and preserved focus, one-shot clearing, failure paths, stale authoritative reads, and lifecycle fences. | The fixed result and local focus are current-browser accessibility feedback, not independent proof of Firebase deployment, provider acknowledgement, identity or profile ownership beyond existing authentication, production persistence, membership, dues, role, payment, entitlement, directory eligibility, or live behavior. The tokens contain no name, email, UID, profile, revision, response, error, provider value, or photo data and create no read, write, request, retry, provider call, log, or stored value. Existing validation, name-only payload, authoritative reread, one-attempt/context fences, generic unconfirmed-change recovery, and exact service calls remain unchanged. Failure/retry focus, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain open separate outcomes. Complete #118/#105/#133/#136 backend-first and publication proof before calling the profile repair or this result live. Directory availability remains `false`; #623 remains the inert deployed preview; #507 retains all optional-directory gates. No Function, Rule, schema, index, service, package, workflow, provider, account, sign-in, production-data, deployment, publication, photo-query, facial-recognition, matching, embedding, similarity, biometric, roster, or live action occurs. Active #616 OAUTH-001A2L and its RISK-024 source hunk remain unchanged. | | 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 request result after provider success or failure. AUTH-MAIL-002C2 [#194](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/194) tracks the source-only `/auth/action` verification path: the initial capability suppresses Sentry/App Check, the page removes native and router query/fragment state, a scanner-style page load makes no action-code check/apply and no account mutation, and one deliberate action requires provider `VERIFY_EMAIL` before apply. Fixed results expose no email, code, raw provider error, account identity, or provider-directed navigation. Synthetic tests use mocks and canary values only. | Publish and verify each exact frontend revision separately. The existing #99 Pages bridge briefly uses tab-local session storage for the return route and deletes it before React; a failed root load can leave it until tab close, while direct-rewrite hosting avoids that residual. #194 adds no storage write. #118 profile source is merged but live behavior is unproven. Keep delivery, Spam, DNS, templates, provider handler choice, and private Firebase email-enumeration-protection readback under #119. Firebase uses one custom handler for verification, password reset, and email recovery; never point its global action URL at the verification-only #194 route until every enabled mode is safely handled, or keep the default multi-mode handler. Accepted requests do not prove delivery, browser cooldowns are not abuse controls, Auth verification does not grant membership, and the Firestore verification mirror remains unfinished AUTH-001 work. | | RISK-041 | WEB-PRIVACY-001W [#496](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/496) adds source-only containment to the Admin Product editor. One mounted page synchronously admits at most one valid save for its current route, Firestore reference, and exact authenticated admin UID. Pending and unknown results hide the complete form and actions; a rejection is discarded without binding or inspection and becomes one fixed accessible stop result. Missing identity or database state starts no save, obsolete or unmounted completions are inert, and the existing exact create/update projections plus current successful navigation stay unchanged. | This browser guard resets on navigation or reload and does not make a repeated write safe. Direct client writes, missing durable command identity, version fencing, audit, private readback, reconciliation, authorization hardening, backup, rollback, Firebase/Rules deployment, website publication, exact live revision, and production behavior remain unproven. Keep the Admin screen unavailable and replace it with a server-authoritative idempotent command before officer use. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 43ec822..51c84d3 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -624,6 +624,8 @@ DATA-001C1 [#178](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/178) pau The server chooses initial timestamps. The current self-edit path sends a Firestore server timestamp, but the Rules source type-checks rather than independently proves that edit timestamp. Do not describe arbitrary profile edit timestamps as server-authoritative until a coordinated Rules/API issue closes that residual. +**AUTH-006G confirmed profile-name save result focus — SOURCE ONLY:** [#651](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/651) changes only confirmed full-name Save feedback and keyboard-focus settlement in My Account. Existing validation, synchronous one-attempt admission, name-only write payload, captured application/Firestore/identity/UID context, authoritative profile reread, and current-attempt checks remain unchanged. Only an exact current update followed by a current successful non-null reread renders the fixed result **Profile name saved.** The result contains no member-entered or returned name, email, UID, revision, provider detail, or caught value; it has status, polite live, atomic, programmatic-focus, bounded 320-pixel layout, and scoped 3-pixel `#005bd8` focus-outline semantics. The Profile-header DOM order is heading, result, then **Edit**; a two-column grid keeps the heading and Edit presentation on the first row while the result spans the second, so Tab after the focused result reaches Edit. After validation and exact save admission, separate pending and result focus refs may retain only the opaque profile generation and attempt ID, and only when the exact connected **Save** button owns focus. Exact current confirmed success transfers the matching token. The layout effect consumes the result intent before target checks, requires the matching current generation and attempt plus the connected result node, leaves an already-focused result alone, returns absent, body, document-root, or disconnected focus to the result, and preserves every other connected outside or in-Profile focus deliberately chosen while the save is pending. A valid unfocused or programmatic Save still shows the truthful result but never moves focus, including after its outside focus origin disappears. Initial load, validation failure, update rejection, missing or rejected confirmation read, profile reload, application/Firestore/identity/UID change, an unavailable-to-same-context generation change, a newer attempt, stale completion, unmount, and later rerender cannot show or reuse a stale result intent; **Edit**, a new admitted Save, profile load, and context change clear the prior result and obsolete intents. The result and focus handoff add no read, write, request, retry, provider call, log, or stored value. Existing unconfirmed-change copy and recovery remain unchanged; failure/retry focus, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain separate outcomes. This adds one visible page-structure node and no data movement, permission, ownership, service contract, Function, Rule, schema, index, package, workflow, provider configuration, account, sign-in state, production-data action, deployment, publication, membership, dues, role, payment, entitlement, roster, photo query, facial recognition, matching, embedding, similarity, biometric processing, or live-behavior change. The #118 backend-first profile-repair proof remains separate. Directory availability remains byte-for-byte `false`, live #623 remains inert, and #507 retains every optional-directory privacy, authorization, staging, deployment/readback, availability-flip, publication, and live-proof gate. + ### 8.0p Private profile thumbnail and officer-finder preference — SOURCE ONLY, NOT LIVE MEMBERS-DIRECTORY-001A [#505](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/505) is the first source slice of parent [#504](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/504). It adds an optional account thumbnail and an independent preference used by the separate source-only officer people finder in #506. Missing preference means hidden. Uploading a photo does not enable discoverability, and opting out does not delete or change the account, role, membership, registration, or payment record. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 5cd865d..8abe9b3 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1072,12 +1072,18 @@ flowchart LR New --> Read Read --> Name["Display and edit name"] Read --> Pause["Do not display or accept phone"] - Name --> Rules["Firebase Rules allow name-only update"] + Name --> Save["Save one name-only update"] + Save --> Rules["Firebase Rules check the name-only update"] + Rules --> Updated{"Current update completes?"} + Updated -- "No" --> Retry["Hide Edit and show Try profile again"] + Updated -- "Yes" --> Confirm{"Current profile reread returns a profile?"} + Confirm -- "Yes" --> Result["Show Profile name saved.\nRestore otherwise-lost focus only if Save owned it"] + Confirm -- "No" --> Retry PhoneAttempt["Browser tries a phone change"] --> Deny["Firebase Rules deny"] Pause --> Existing["Existing stored value stays unchanged"] ``` -In words: signup or profile recovery creates a missing pending profile without copying a phone from Firebase Auth; My Account shows and edits the member's name, does not display or accept a phone number, and leaves every existing profile unchanged; the reviewed Rules deny a browser phone change. +In words: signup or profile recovery creates a missing pending profile without copying a phone from Firebase Auth; My Account shows and edits the member's name, does not display or accept a phone number, and leaves every existing stored phone value unchanged; after one current name-only update completes and its current profile reread returns a profile, My Account shows **Profile name saved.** and restores otherwise-lost focus to that result only when Save owned focus, while an unconfirmed change keeps the existing Try profile again recovery; the reviewed Rules deny a browser phone change. Officer steps after every prerequisite has proof: @@ -1100,6 +1106,152 @@ Officer steps after every prerequisite has proof: **Escalation:** membership lead plus privacy/platform owner; use the private incident path under #112 if exposure is suspected. +Officer source-review procedure for AUTH-006G [#651] confirmed profile-name Save result focus — source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that one exact current confirmed full-name Save shows the fixed result **Profile name saved.**, and that only a Save which owned focus may return otherwise-lost focus to that result, without using a real account, changing Firebase, or publishing the website. + +**Approvers:** membership lead, identity/privacy owner, and platform/security owner. + +**Prerequisites:** released #531/PR #534 supplies the existing one-attempt and account-context fences. Ask the platform owner or testing specialist for the exact #651 source candidate, the trustworthy unchanged-runtime named failure, the complete passing AUTH-006G named result, the full Account result, type-check and scoped-lint results, the bounded focus-style evidence, the exact diff check, and a redacted written synthetic-behavior report. The specialist runs every command and records the evidence. The backup officer reviews only those written records and prepared artifacts; the backup officer does not use a terminal or test harness. In this procedure, a profile generation and attempt ID are two internal numbers that identify one page lifetime and one Save; they contain no member detail. Keep the optional-directory availability value `false`. Do not sign in to production, use a real name, inspect a real profile, call production Firebase, configure a provider, or change production data. + +1. Keep the confirmed profile-name result marked **SOURCE ONLY, NOT LIVE**. +2. Ask the platform owner for the exact #651 issue. +3. Ask the platform owner for the reviewed pull request when one exists. +4. Ask the platform owner for the exact candidate or merge commit. +5. Ask the platform owner for the three-path runtime diff. +6. Ask the platform owner for runtime diff digest `c00a6c7e0486dcdfbb0e9d0a4b4c48215f03e3e8c72634fc1f2b17142c08c1d5`. +7. Ask the testing specialist for the trustworthy unchanged-runtime result with one expected failure and 243 skipped tests. +8. Ask the testing specialist for test-only digest `df02329a39ee24b127f801d4a8726f5748bdff0b992b8706a16456bbeb2fd66f` for that unchanged-runtime result. +9. Ask the testing specialist for the green 22-of-22 AUTH-006G result. +10. Ask the testing specialist for the green 265-of-265 full Account result. +11. Ask the testing specialist for the passing type-check result. +12. Ask the testing specialist for the passing scoped ESLint result. +13. Ask the testing specialist for the passing diff-check result. +14. Ask the testing specialist for the bounded grid, containment, and focus-style evidence. +15. Ask the testing specialist for the redacted written synthetic-behavior report. +16. Confirm the report names the specialist who ran the checks. +17. Confirm the backup officer used no terminal. +18. Confirm every profile and account in the evidence is made up. +19. Confirm no real name appears in the evidence. +20. Confirm no production Firebase call appears in the evidence. +21. Confirm the success result says exactly **Profile name saved.**. +22. Confirm the result appears only after the exact current update completes. +23. Confirm the result appears only after the exact current authoritative profile reread returns a profile. +24. Confirm a returned current profile supplies the displayed saved name. +25. Confirm the same returned current profile supplies the existing directory display-name input. +26. Confirm the result contains no entered member name. +27. Confirm the result contains no email. +28. Confirm the result contains no UID. +29. Confirm the result contains no revision. +30. Confirm the result contains no provider or caught-error detail. +31. Confirm the result has status semantics. +32. Confirm the result has polite live semantics. +33. Confirm the result has atomic semantics. +34. Confirm the result has a programmatic-only tab position of `-1`. +35. Confirm the result uses the scoped `account-profile__save-result` class. +36. Confirm focused result evidence shows a 3-pixel outline. +37. Confirm focused result evidence shows outline color `#005bd8`. +38. Confirm focused result evidence shows a 3-pixel outline offset. +39. Confirm the result has bounded width and minimum-width rules. +40. Confirm the result has a bounded maximum-width rule. +41. Confirm the result wraps long content instead of overflowing. +42. Confirm the Profile header remains contained at 320 pixels. +43. Confirm Profile-header DOM order is heading, result, then **Edit**. +44. Confirm the grid keeps the heading on the first visual row. +45. Confirm the grid keeps Edit on the first visual row. +46. Confirm the result spans the second visual row. +47. Confirm the next Tab after the focused result reaches Edit. +48. Confirm validation finishes before a focus intent can be created. +49. Confirm synchronous one-attempt admission finishes before a focus intent can be created. +50. Confirm the exact connected Save button must own focus to create the intent. +51. Confirm the pending intent records only the current profile generation. +52. Confirm the pending intent records only the exact attempt ID. +53. Confirm the intent records no member or profile detail. +54. Confirm only exact current confirmed success transfers the matching intent. +55. Confirm the layout effect consumes the transferred intent before target checks. +56. Confirm the current profile generation must match before focus. +57. Confirm the current attempt ID must match before focus. +58. Confirm the result node must still be connected before focus. +59. Confirm a result that already retained focus is not focused again. +60. Confirm absent focus returns to the result. +61. Confirm document-body focus returns to the result. +62. Confirm document-root focus returns to the result. +63. Confirm disconnected focus returns to the result. +64. Confirm connected focus outside Profile remains focused. +65. Confirm connected focus inside Profile remains focused. +66. Confirm a valid unfocused Save still shows the success result. +67. Confirm a valid unfocused Save moves no focus. +68. Confirm removing an unfocused Save's outside focus origin causes no delayed focus. +69. Confirm one result intent can move focus only once. +70. Confirm a later same-context rerender cannot repeat consumed focus. +71. Confirm choosing Edit clears the prior success result. +72. Confirm a new admitted Save clears the prior success result. +73. Confirm a profile load clears the prior success result. +74. Confirm a profile-context change clears the prior success result. +75. Confirm the initial profile load shows no save-success result. +76. Confirm validation failure shows no save-success result. +77. Confirm validation failure starts no profile write. +78. Confirm update rejection keeps the existing unconfirmed-change result. +79. Confirm a missing confirmation reread keeps the existing unconfirmed-change result. +80. Confirm a rejected confirmation reread keeps the existing unconfirmed-change result. +81. Confirm an application-only change makes old save success inert. +82. Confirm a Firestore-service-only change makes old save success inert. +83. Confirm an identity-service-only change makes old save success inert. +84. Confirm a UID-only change makes old save success inert. +85. Confirm unavailable-to-same-context return invalidates the older generation. +86. Confirm a stale update completion cannot show or focus old success. +87. Confirm a stale authoritative reread cannot show or focus old success. +88. Confirm a newer attempt cannot reuse an older intent. +89. Confirm unmount makes a pending save result- and focus-inert. +90. Confirm one admitted confirmed Save makes exactly one update call. +91. Confirm one admitted confirmed Save makes exactly one confirmation reread. +92. Confirm result focus creates no additional read. +93. Confirm result focus creates no additional write. +94. Confirm result focus creates no request or retry. +95. Confirm result focus creates no provider call. +96. Confirm result focus creates no log or stored value. +97. Confirm existing unconfirmed-save retry focus remains separate. +98. Confirm validation error association remains separate. +99. Confirm Edit-to-input focus remains separate. +100. Confirm Cancel-to-Edit focus remains separate. +101. Confirm the source diff changes no Account service or profile payload. +102. Confirm the source diff changes no Function or Firestore Rule. +103. Confirm the source diff changes no schema or index. +104. Confirm the source diff changes no package or workflow. +105. Confirm the source diff changes no provider configuration. +106. Confirm the source diff changes no account or sign-in state. +107. Confirm the source diff changes no production data. +108. Confirm the source diff adds no membership, dues, role, payment, entitlement, or roster claim. +109. Confirm the source diff adds no photo query or facial recognition. +110. Confirm the source diff adds no matching, embedding, similarity, or biometric processing. +111. Confirm active #616 Strava runtime files remain unchanged. +112. Confirm active #616 OAUTH-001A2L/RISK-024 documentation remains unchanged. +113. Confirm the optional-directory availability value remains byte-for-byte `false`. +114. Confirm the last verified production directory deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +115. Confirm #507 still owns every optional-directory connection and live-proof gate. +116. Record the source change as its own state. +117. Record the named test results as their own state. +118. Record whether the change merged as its own state. +119. Record whether any website artifact was published as its own state. +120. Record the exact `runmprc.com` revision as its own state. +121. Record whether Firebase was deployed as its own state. +122. Record whether an outside provider was configured as its own state. +123. Record whether an account or sign-in state changed as its own state. +124. Record whether production data changed as its own state. +125. Record whether the confirmed profile-name result is live as its own state. +126. Record whether connected profile-photo or officer-finder behavior is live as its own state. +127. Stop before changing Firebase, a provider, an account, production data, directory availability, or the live website. + +**Expected result:** one exact current full-name update plus its successful non-null authoritative profile reread renders exactly **Profile name saved.** between the Profile heading and Edit, with status, polite live, atomic, programmatic-focus, bounded-layout, and visible-outline semantics. Only an admitted Save that owned focus records the current generation and attempt ID. Exact current confirmed success alone transfers that intent. The layout effect consumes it before target checks. Absent, body, document-root, or disconnected focus returns to the connected result; retained result focus is left alone; and every other connected focus is preserved. An unfocused Save still shows the truthful result without moving focus. Initial load, validation, update rejection, missing or rejected reread, profile reload, context or generation change, newer attempt, stale work, unmount, and later rerender cannot show or focus stale success. Edit, a new Save, profile load, and context change clear the old result. The handoff creates no extra read, write, request, retry, provider call, log, or stored value. Existing unconfirmed-change recovery remains unchanged. The behavior is source only and **NOT LIVE** until separately published and verified. Directory availability remains `false`, and live #623 remains inert. + +**Stop conditions:** a real account, profile, name, email, UID, screenshot, or production sign-in; direct production Firebase access; a provider configuration or production-data action; success copy other than **Profile name saved.**; a result shown before a current non-null authoritative reread; a result containing a member, provider, or error detail; missing status, live, atomic, programmatic-focus, bounded-layout, or visible-outline semantics; DOM order other than heading, result, Edit; a focused exact current Save whose confirmed result receives no otherwise-lost focus; an unfocused Save that moves focus; a connected control that loses deliberately selected focus; an intent containing anything beyond generation and attempt ID; transfer before current confirmed success; delayed focus on a later render; stale application, Firestore, identity, UID, generation, attempt, reread, or unmounted work that shows or focuses success; an extra read, write, request, retry, provider call, log, or stored value; changed validation, payload, confirmation-reread, AUTH-006F, unconfirmed-change, failure/retry, Edit-to-input focus, Cancel-to-Edit focus, directory, or Strava behavior; a Function, Rule, schema, index, package, workflow, provider, account, sign-in, production-data, deployment, publication, membership, dues, role, payment, entitlement, roster, photo-query, facial-recognition, matching, embedding, similarity, or biometric change; an availability flip; use of a terminal or test harness by the backup officer; or a claim that source, tests, merge, preview, or green CI proves the behavior live. + +**Success proof:** record the exact #651 issue, reviewed pull request and commit; trustworthy unchanged-runtime result with one expected failure and 243 skipped tests plus test-only digest `df02329a39ee24b127f801d4a8726f5748bdff0b992b8706a16456bbeb2fd66f`; green 22-of-22 AUTH-006G block; green 265-of-265 Account suite; passing type-check, scoped ESLint, and diff-check; bounded grid, containment, and focus-style evidence; runtime diff digest `c00a6c7e0486dcdfbb0e9d0a4b4c48215f03e3e8c72634fc1f2b17142c08c1d5`; relevant full frontend, repository Node, diagnostic build, unchanged lint-baseline, workflow, and security checks when complete; independent frontend/accessibility, security/privacy/race, and backup-officer reviews; and exact-main CI if merged. Record source, tests, merge, website publication, exact `runmprc.com` revision, Firebase deployment, provider configuration, account/sign-in change, production-data action, and live behavior separately. Record the unchanged `false` directory availability and unchanged #623 deploy separately. Source and tests do not prove merge. Merge does not prove publication. Publication does not prove `runmprc.com`, Firebase, provider, account, data, or live behavior. + +**Undo:** use one reviewed frontend-and-documentation revert or safe roll-forward. No Firebase, provider, account, sign-in, or production-data undo is needed because #651 changes source only. An undo must preserve AUTH-006F context fencing, generic unconfirmed-change recovery, the inert directory default, and active #616 work. Do not undo by editing or deleting a profile or account. + +**Escalation:** membership lead plus identity/privacy and platform/security owners. Use the private incident path if a real profile detail appeared, focus crossed application or account contexts, a stale result appeared, deliberately selected connected focus was stolen, an intent retained private or service data, a focus handoff created a read or write, or any source-only behavior became connected or live without separate approval. + ## Provider-neutral membership authority — SOURCE ONLY, UNUSED **Status: NOT AVAILABLE YET** diff --git a/src/pages/account/Account.css b/src/pages/account/Account.css index 9f57c72..40983f5 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -1,3 +1,47 @@ +.account-profile__header { + display: grid; + min-width: 0; + margin-bottom: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: 1rem; + row-gap: 0.75rem; +} + +.account-profile__header h2 { + min-width: 0; + margin: 0; + grid-column: 1; + grid-row: 1; +} + +.account-profile__edit { + grid-column: 2; + grid-row: 1; +} + +.account-profile__save-result { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + margin: 0; + padding: 0.75rem; + grid-column: 1 / -1; + grid-row: 2; + color: #14532d; + background: #f0fdf4; + border: 2px solid #86efac; + border-radius: 0.375rem; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.account-profile__save-result:focus { + outline: 3px solid #005bd8; + outline-offset: 3px; +} + .account-verification-notice { display: flex; margin-top: 0.75rem; diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index a5c8bd4..5b2c989 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -559,6 +559,434 @@ describe('Account profile recovery', () => { expect(screen.queryByTestId('strava-section')).not.toBeInTheDocument(); }); + describe('AUTH-006G profile-save success feedback and focus', () => { + const CONFIRMED_PROFILE = { + ...PROFILE, + fullName: 'Confirmed Synthetic Member', + }; + + async function startDeferredConfirmedSave({ + focusSave = true, + nextProfile = CONFIRMED_PROFILE, + }: { + focusSave?: boolean; + nextProfile?: typeof PROFILE; + } = {}) { + const update = accountDeferred(); + (updateMyProfile as jest.Mock).mockReturnValueOnce(update.promise); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(nextProfile); + const view = renderAccount(); + + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: nextProfile.fullName }, + }); + const save = screen.getByRole('button', { name: 'Save' }); + if (focusSave) save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + return { + nextProfile, save, update, view, + }; + } + + function evictFocusToBody() { + const displaced = document.createElement('button'); + document.body.append(displaced); + displaced.focus(); + displaced.remove(); + expect(document.body).toHaveFocus(); + } + + test('announces and focuses a current confirmed save after native focus eviction', async () => { + const { nextProfile, save, update } = await startDeferredConfirmedSave(); + expect(save).toHaveFocus(); + evictFocusToBody(); + + await act(async () => update.resolve()); + + expect(await screen.findByText(nextProfile.fullName)).toBeInTheDocument(); + const result = screen.getByRole('status'); + expect(result).toHaveTextContent(/^Profile name saved\.$/); + expect(result).toHaveAttribute('aria-live', 'polite'); + expect(result).toHaveAttribute('aria-atomic', 'true'); + expect(result).toHaveAttribute('tabindex', '-1'); + expect(result).toHaveClass('account-profile__save-result'); + expect(result).toHaveFocus(); + const heading = screen.getByRole('heading', { level: 2, name: 'Profile' }); + const edit = screen.getByRole('button', { name: 'Edit' }); + expect(heading.nextElementSibling).toBe(result); + expect(result.nextElementSibling).toBe(edit); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(updateMyProfile).toHaveBeenCalledWith( + firestore, + USER.uid, + { fullName: nextProfile.fullName }, + ); + expect(getMyProfile).toHaveBeenCalledTimes(2); + expect(mockMemberDirectoryProfile).toHaveBeenLastCalledWith({ + app, + uid: USER.uid, + displayName: nextProfile.fullName, + }); + }); + + test.each([ + ['no active element', null], + ['the document root', document.documentElement], + ['a disconnected element', document.createElement('button')], + ])('restores focus from %s', async (_label, lostFocus) => { + const { update } = await startDeferredConfirmedSave(); + const activeElement = jest.spyOn(document, 'activeElement', 'get') + .mockImplementation(() => lostFocus); + try { + await act(async () => update.resolve()); + } finally { + activeElement.mockRestore(); + } + + expect(screen.getByRole('status')).toHaveFocus(); + }); + + test.each([ + ['outside the Profile section', 'Sign out'], + ['inside the Profile section', 'Request another verification email'], + ])('preserves connected focus %s', async (_label, buttonName) => { + const { update } = await startDeferredConfirmedSave(); + const deliberateTarget = screen.getByRole('button', { name: buttonName }); + deliberateTarget.focus(); + expect(deliberateTarget).toHaveFocus(); + + await act(async () => update.resolve()); + + expect(screen.getByRole('status')).toHaveTextContent('Profile name saved.'); + expect(deliberateTarget).toHaveFocus(); + }); + + test('shows an unfocused confirmation without moving focus after its origin disappears', async () => { + const outsideOrigin = document.createElement('button'); + document.body.append(outsideOrigin); + outsideOrigin.focus(); + const { update } = await startDeferredConfirmedSave({ focusSave: false }); + expect(outsideOrigin).toHaveFocus(); + outsideOrigin.remove(); + expect(document.body).toHaveFocus(); + + await act(async () => update.resolve()); + + expect(screen.getByRole('status')).toHaveTextContent('Profile name saved.'); + expect(document.body).toHaveFocus(); + }); + + test('leaves an already-focused result alone', async () => { + const { update } = await startDeferredConfirmedSave(); + const focusTargets: HTMLElement[] = []; + const focus = jest.spyOn(HTMLElement.prototype, 'focus').mockImplementation(function recordFocus( + this: HTMLElement, + ) { + focusTargets.push(this); + }); + const activeElement = jest.spyOn(document, 'activeElement', 'get') + .mockImplementation(() => document.querySelector('.account-profile__save-result')); + try { + await act(async () => update.resolve()); + } finally { + activeElement.mockRestore(); + focus.mockRestore(); + } + + const result = screen.getByRole('status'); + expect(result).toHaveTextContent('Profile name saved.'); + expect(focusTargets).not.toContain(result); + }); + + test('consumes focus once and clears the result when editing resumes', async () => { + const { update, view } = await startDeferredConfirmedSave(); + evictFocusToBody(); + await act(async () => update.resolve()); + const result = screen.getByRole('status'); + expect(result).toHaveFocus(); + + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + view.rerender(accountView()); + expect(deliberateTarget).toHaveFocus(); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Full name')).toHaveValue(CONFIRMED_PROFILE.fullName); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('shows no confirmation on the initial profile load', async () => { + renderAccount(); + + expect(await screen.findByText(PROFILE.fullName)).toBeInTheDocument(); + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(getMyProfile).toHaveBeenCalledTimes(1); + expect(updateMyProfile).not.toHaveBeenCalled(); + }); + + test('keeps validation failure local without a success result or write', async () => { + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: '🏃'.repeat(101) }, + }); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Full name must be 200 characters or fewer.', + ); + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(save).toHaveFocus(); + expect(updateMyProfile).not.toHaveBeenCalled(); + expect(getMyProfile).toHaveBeenCalledTimes(1); + }); + + test('keeps a rejected update on the existing unconfirmed path', async () => { + const update = accountDeferred(); + (updateMyProfile as jest.Mock).mockReturnValueOnce(update.promise); + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + + await act(async () => update.reject(new Error('synthetic-private-rejection'))); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'We could not confirm your profile change.', + ); + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('synthetic-private-rejection'); + expect(getMyProfile).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['missing', null], + ['rejected', new Error('synthetic-private-confirmation-rejection')], + ])('keeps a %s confirmation read on the existing unconfirmed path', async ( + outcome, + confirmationResult, + ) => { + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE); + if (outcome === 'missing') { + (getMyProfile as jest.Mock).mockResolvedValueOnce(confirmationResult); + } else { + (getMyProfile as jest.Mock).mockRejectedValueOnce(confirmationResult); + } + renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'We could not confirm your profile change.', + ); + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('synthetic-private-confirmation-rejection'); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test.each([ + [ + 'UID', + { + services: { + firebaseResources: { app, firestore }, + identityService: { signOut, resendVerificationEmail }, + }, + user: { + uid: 'synthetic-user-b', + email: 'member-b@example.test', + role: 'unverified' as const, + }, + }, + ], + [ + 'Firebase app', + { + services: { + firebaseResources: { app: { name: 'synthetic-app-b' }, firestore }, + identityService: { signOut, resendVerificationEmail }, + }, + user: USER, + }, + ], + [ + 'Firestore service', + { + services: { + firebaseResources: { + app, + firestore: { name: 'synthetic-firestore-b' }, + }, + identityService: { signOut, resendVerificationEmail }, + }, + user: USER, + }, + ], + [ + 'identity service', + { + services: { + firebaseResources: { app, firestore }, + identityService: { + signOut: jest.fn(), + resendVerificationEmail: jest.fn(), + }, + }, + user: USER, + }, + ], + ])('does not confirm or focus an old save after a %s-only change', async ( + _transition, + nextContext, + ) => { + const oldUpdate = accountDeferred(); + const currentProfile = { + ...PROFILE, + uid: nextContext.user.uid, + email: nextContext.user.email, + fullName: 'Current Context Profile', + }; + (updateMyProfile as jest.Mock).mockReturnValueOnce(oldUpdate.promise); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(currentProfile); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + + (useServiceLocator as jest.Mock).mockReturnValue({ + services: nextContext.services, + isReady: true, + }); + view.rerender(accountView(nextContext.user)); + expect(await screen.findByText(currentProfile.fullName)).toBeInTheDocument(); + await act(async () => oldUpdate.resolve()); + + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(screen.getByText(currentProfile.fullName)).toBeInTheDocument(); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('does not confirm or move focus when the old authoritative read settles after a UID change', async () => { + const oldConfirmation = accountDeferred(); + const nextUser = { + uid: 'synthetic-user-b', + email: 'member-b@example.test', + role: 'unverified' as const, + }; + const currentProfile = { + ...PROFILE, + uid: nextUser.uid, + email: nextUser.email, + fullName: 'Current Context Profile', + }; + (updateMyProfile as jest.Mock).mockResolvedValueOnce(undefined); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockReturnValueOnce(oldConfirmation.promise) + .mockResolvedValueOnce(currentProfile); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + await waitFor(() => expect(getMyProfile).toHaveBeenCalledTimes(2)); + + view.rerender(accountView(nextUser)); + expect(await screen.findByText(currentProfile.fullName)).toBeInTheDocument(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + expect(deliberateTarget).toHaveFocus(); + + await act(async () => oldConfirmation.resolve(CONFIRMED_PROFILE)); + + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(screen.getByText(currentProfile.fullName)).toBeInTheDocument(); + expect(deliberateTarget).toHaveFocus(); + expect(getMyProfile).toHaveBeenCalledTimes(3); + }); + + test('invalidates confirmation focus across unavailable and same-context return', async () => { + const services = { + firebaseResources: { app, firestore }, + identityService: { signOut, resendVerificationEmail }, + }; + const oldUpdate = accountDeferred(); + const restoredProfile = { + ...PROFILE, + fullName: 'Restored Synthetic Member', + }; + (useServiceLocator as jest.Mock).mockReturnValue({ services, isReady: true }); + (updateMyProfile as jest.Mock).mockReturnValueOnce(oldUpdate.promise); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(restoredProfile); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + + (useServiceLocator as jest.Mock).mockReturnValue({ services: null, isReady: false }); + view.rerender(accountView()); + expect(screen.getByRole('status')).toHaveTextContent('Loading profile...'); + (useServiceLocator as jest.Mock).mockReturnValue({ services, isReady: true }); + view.rerender(accountView()); + expect(await screen.findByText(restoredProfile.fullName)).toBeInTheDocument(); + await act(async () => oldUpdate.resolve()); + + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('makes a focused pending save inert after unmount', async () => { + const update = accountDeferred(); + (updateMyProfile as jest.Mock).mockReturnValueOnce(update.promise); + (getMyProfile as jest.Mock).mockResolvedValueOnce(PROFILE); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const save = screen.getByRole('button', { name: 'Save' }); + save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + view.unmount(); + + await act(async () => update.resolve()); + + expect(document.body).not.toHaveTextContent('Profile name saved.'); + expect(getMyProfile).toHaveBeenCalledTimes(1); + }); + + test('uses bounded grid, containment, and visible-focus CSS rules', () => { + const css = readFileSync(join(__dirname, 'Account.css'), 'utf8'); + + expect(css).toMatch(/\.account-profile__header\s*\{[^}]*display:\s*grid;[^}]*min-width:\s*0;[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s+auto;[^}]*\}/); + expect(css).toMatch(/\.account-profile__header h2\s*\{[^}]*grid-column:\s*1;[^}]*grid-row:\s*1;[^}]*\}/); + expect(css).toMatch(/\.account-profile__edit\s*\{[^}]*grid-column:\s*2;[^}]*grid-row:\s*1;[^}]*\}/); + expect(css).toMatch(/\.account-profile__save-result\s*\{[^}]*box-sizing:\s*border-box;[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;[^}]*overflow-wrap:\s*anywhere;[^}]*\}/); + expect(css).toMatch(/\.account-profile__save-result\s*\{[^}]*grid-column:\s*1\s*\/\s*-1;[^}]*grid-row:\s*2;[^}]*color:\s*#14532d;[^}]*background:\s*#f0fdf4;[^}]*\}/); + expect(css).toMatch(/\.account-profile__save-result:focus\s*\{[^}]*outline:\s*3px\s+solid\s+#005bd8;[^}]*outline-offset:\s*3px;[^}]*\}/); + }); + }); + describe('AUTH-006F profile-save context isolation', () => { const userB = { uid: 'synthetic-user-b', diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index 7500ff0..4b47126 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -234,6 +234,7 @@ function RegistrationRow({ const PROFILE_UNAVAILABLE_MESSAGE = 'Your profile is temporarily unavailable. Sign out and try again later. No membership or payment status was changed.'; const PROFILE_CHANGE_UNCONFIRMED_MESSAGE = 'We could not confirm your profile change. Try the profile again before making another change.'; +const PROFILE_SAVE_CONFIRMED_MESSAGE = 'Profile name saved.'; const SIGN_OUT_PENDING_MESSAGE = 'Signing out. Keep this page open.'; const SIGN_OUT_RETRY_MESSAGE = 'We could not confirm sign-out. You may still be signed in. Try sign out once more.'; const SIGN_OUT_TERMINAL_MESSAGE = 'We still could not confirm sign-out. You may still be signed in. Close the browser and do not let anyone else use this device until the membership lead or platform owner helps.'; @@ -254,6 +255,10 @@ export function AccountContent({ const [fullName, setFullName] = useState(''); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [profileSaveConfirmation, setProfileSaveConfirmation] = useState<{ + attemptId: number; + generation: number; + } | null>(null); const [regsData, setRegsData] = useState(null); const [regsLoading, setRegsLoading] = useState(true); @@ -272,7 +277,8 @@ export function AccountContent({ type ProfileDataContext = ProfileServiceContext & { generation: number; }; - type ProfileSaveAttempt = ProfileDataContext & { attemptId: number }; + type ProfileSaveToken = { attemptId: number; generation: number }; + type ProfileSaveAttempt = ProfileDataContext & ProfileSaveToken; type SignOutOutcome = AccountContext & { attemptId: number; attemptNumber: 1 | 2; @@ -286,6 +292,10 @@ export function AccountContent({ const [signOutOutcome, setSignOutOutcome] = useState(null); const profileSaveAttemptIdRef = useRef(0); const profileSaveBlockedRef = useRef(false); + const pendingProfileSaveFocusIntentRef = useRef(null); + const profileSaveResultFocusIntentRef = useRef(null); + const profileSaveResultRef = useRef(null); + const profileSaveButtonRef = useRef(null); const profileContextGenerationRef = useRef(0); const profileContextMountedRef = useRef(false); const currentProfileContextRef = useRef({ @@ -318,11 +328,16 @@ export function AccountContent({ setSaving(false); setEditing(false); setSaveError(null); + setProfileSaveConfirmation(null); + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = null; return () => { profileContextMountedRef.current = false; profileContextGenerationRef.current += 1; profileSaveBlockedRef.current = true; + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = null; }; }, [firebaseApp, firebaseFirestore, identityService, user.uid]); @@ -364,6 +379,9 @@ export function AccountContent({ setProfileError(null); setEditing(false); setSaveError(null); + setProfileSaveConfirmation(null); + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = null; try { await ensureMyProfile(activeServices.firebaseResources.app); @@ -430,10 +448,21 @@ export function AccountContent({ && currentContext.uid === attempt.uid; } + function matchesProfileSaveToken( + token: ProfileSaveToken | null, + expected: ProfileSaveToken, + ) { + return token?.generation === expected.generation + && token.attemptId === expected.attemptId; + } + async function handleSave() { if (!services) return; const validation = validateMemberProfileFields({ fullName }); if (!validation.valid) { + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = null; + setProfileSaveConfirmation(null); setSaveError(validation.message); return; } @@ -461,6 +490,14 @@ export function AccountContent({ attemptId, generation: profileContextGenerationRef.current, }; + const focusToken = { attemptId, generation: attempt.generation }; + const saveButton = profileSaveButtonRef.current; + pendingProfileSaveFocusIntentRef.current = saveButton?.isConnected + && document.activeElement === saveButton + ? focusToken + : null; + profileSaveResultFocusIntentRef.current = null; + setProfileSaveConfirmation(null); setSaving(true); setSaveError(null); try { @@ -477,9 +514,22 @@ export function AccountContent({ setFullName(fresh.fullName || ''); setProfileError(null); setProfileState('ready'); + const confirmation = { attemptId, generation: attempt.generation }; + const pendingFocusIntent = pendingProfileSaveFocusIntentRef.current; + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = matchesProfileSaveToken( + pendingFocusIntent, + confirmation, + ) + ? confirmation + : null; + setProfileSaveConfirmation(confirmation); setEditing(false); } catch { if (!isCurrentProfileSave(attempt)) return; + pendingProfileSaveFocusIntentRef.current = null; + profileSaveResultFocusIntentRef.current = null; + setProfileSaveConfirmation(null); setProfile(null); setEditing(false); setSaveError(null); @@ -548,6 +598,32 @@ export function AccountContent({ } } + useLayoutEffect(() => { + const focusIntent = profileSaveResultFocusIntentRef.current; + profileSaveResultFocusIntentRef.current = null; + if (!focusIntent) return; + if (!matchesProfileSaveToken(profileSaveConfirmation, focusIntent)) return; + if ( + profileContextGenerationRef.current !== focusIntent.generation + || profileSaveAttemptIdRef.current !== focusIntent.attemptId + || profileState !== 'ready' + || editing + || !profile + ) return; + + const target = profileSaveResultRef.current; + if (!target?.isConnected) return; + const { activeElement } = document; + if (activeElement === target) return; + if ( + activeElement + && activeElement.isConnected + && activeElement !== document.body + && activeElement !== document.documentElement + ) return; + target.focus(); + }, [editing, profile, profileSaveConfirmation, profileState]); + const currentSignOutOutcome = signOutOutcome && signOutOutcome.uid === user.uid && signOutOutcome.identityService === identityService @@ -561,6 +637,11 @@ export function AccountContent({ && profileContext.firebaseApp === firebaseApp && profileContext.firebaseFirestore === firebaseFirestore && profileContext.generation === profileContextGenerationRef.current; + const currentProfileSaveConfirmation = profileSaveConfirmation + && profileSaveConfirmation.generation === profileContextGenerationRef.current + && profileSaveConfirmation.attemptId === profileSaveAttemptIdRef.current + ? profileSaveConfirmation + : null; const registrationsBelongToCurrentContext = registrationsContext && registrationsContext.uid === user.uid && registrationsContext.identityService === identityService @@ -667,13 +748,33 @@ export function AccountContent({
-
+

Profile

+ {currentProfileSaveConfirmation + && !editing + && profileState === 'ready' + && profile && ( +

+ {PROFILE_SAVE_CONFIRMED_MESSAGE} +

+ )} {!editing && profileState === 'ready' && profile && ( @@ -754,6 +855,7 @@ export function AccountContent({ )}