From bfd36ab08661d5ee67f59b2e2b78d13527a64b2e Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 21:34:06 -0700 Subject: [PATCH] Restore focus for unconfirmed profile saves --- IMPLEMENTATION_PLAN.md | 4 +- SECURITY.md | 3 +- SYSTEM_DESIGN.md | 4 +- docs/officers/EVENTS_SHOP_MEMBERS.md | 152 ++++++++- src/pages/account/Account.css | 13 + src/pages/account/Account.test.tsx | 480 +++++++++++++++++++++++++++ src/pages/account/Account.tsx | 97 +++++- 7 files changed, 744 insertions(+), 9 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1652aff..3a68d46 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -169,7 +169,9 @@ 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. +**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. AUTH-006H [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) separately owns unconfirmed-change alert 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. + +**AUTH-006H current source boundary:** [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) changes only keyboard-focus settlement for the existing unconfirmed full-name Save recovery. After validation and synchronous one-attempt admission, the existing pending Save-focus token is armed only while the exact connected Save button owns focus and retains only the opaque profile generation and attempt ID. An exact current update rejection, `null` confirmation reread, or rejected confirmation reread creates a minimal unconfirmed-result token for the current generation and attempt and transfers a matching pending focus token only to the separate unconfirmed focus ref; it does not guess whether the update persisted. The existing alert keeps the byte-exact message **We could not confirm your profile change. Try the profile again before making another change.**, contains no member or service detail, and now has assertive alert, atomic live-region, programmatic-focus, bounded wrapping, and scoped visible-outline semantics only for this exact current save-unconfirmed state. Its message remains immediately before the enabled **Try profile again** button, making that action the next Tab stop. One layout effect consumes the intent before target checks; it requires the exact current generation and attempt, unavailable state, no profile, editor, or success confirmation, the exact message, and a connected alert. It leaves retained alert focus alone, restores absent, body, document-root, or disconnected focus, and preserves any other connected deliberate focus. Unfocused or programmatic Save still shows the recovery without moving focus, including after its outside origin disappears. Initial setup/read failure, validation failure, reload, application/Firestore/identity/UID or generation change, newer attempt, obsolete update/reread work, unmount, and later rerender cannot focus a stale result; Try clears all old Save intents before the existing load. AUTH-006G success, validation, the name-only payload, update/reread behavior, one-attempt/context fences, fixed recovery copy/action, and exact service calls remain unchanged. The handoff adds no read, write, request, retry, provider call, log, stored value, page node, data movement, permission, ownership, service contract, Function, Rule, schema, index, package, workflow, provider, account, sign-in, production-data action, deployment, publication, membership, dues, role, payment, entitlement, roster, biometric processing, or live behavior. Later Try-profile-again result focus, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain separate. 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. diff --git a/SECURITY.md b/SECURITY.md index e52977a..13521e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -168,7 +168,8 @@ 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. | +| 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. AUTH-006H [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) separately owns unconfirmed-change alert 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. | +| AUTH-006H / source-only unconfirmed-save interface containment for RISK-039 | [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) reuses the existing full-name Save unconfirmed alert and **Try profile again** action. After validation and synchronous one-attempt admission, the pending focus token is armed only while the exact connected Save button owns focus and retains only the opaque profile generation and attempt ID. An exact current update rejection, `null` confirmation reread, or rejected confirmation reread creates separate unconfirmed result ownership with the same minimal generation-and-attempt pair and transfers the pending focus token only when it matches; no browser state classifies whether the update persisted. Only that exact current save-unconfirmed state gives the byte-exact fixed alert assertive, atomic, programmatic-focus, bounded-wrapping, and scoped visible-outline semantics. The message contains no member, profile, provider, response, or caught detail, immediately precedes the enabled retry button, and keeps that action as the next Tab stop. One layout effect consumes the intent before checks and requires the matching current generation and attempt, unavailable state, no profile/editor/success confirmation, exact fixed message, and connected alert. It leaves retained alert focus alone, restores absent, body, document-root, or disconnected focus, and preserves other connected focus. An unfocused/programmatic Save still shows the alert without moving focus. Initial setup/read failure, validation failure, reload, application/Firestore/identity/UID or generation change, newer attempt, stale update/reread settlement, unmount, and later rerender cannot focus a stale result; Try clears the old intents before the existing reload. Synthetic tests use only made-up profiles and cover all three unconfirmed paths, exact calls, fixed copy/DOM/live/CSS semantics, lost and preserved focus, programmatic invocation, initial and validation failures, retry clearing, lifecycle races, newer work, one-shot consumption, and unmount. | The fixed alert and local focus communicate browser uncertainty; they do not prove the update failed or persisted, provider acknowledgement, profile or identity ownership beyond existing authentication, Firebase deployment, production data, membership, dues, role, payment, entitlement, directory eligibility, or live behavior. The state and refs retain no name, email, UID, profile, revision, request, response, error, provider value, or photo data and add no read, write, request, retry, provider call, log, stored value, page node, or data movement. AUTH-006G success, validation, name-only payload, update/reread behavior, context fences, fixed recovery copy/action, and exact service calls remain unchanged. Later Try-profile-again result focus, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain separate. 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 configuration, account, sign-in, production-data, deployment, publication, photo query, facial recognition, matching, embedding, similarity, biometric processing, 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 51c84d3..061a0cd 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -624,7 +624,9 @@ 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. +**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; AUTH-006H [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) separately owns unconfirmed-change alert focus, while 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. + +**AUTH-006H unconfirmed profile-name save result focus — SOURCE ONLY:** [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) changes only keyboard-focus settlement for the existing unconfirmed full-name Save recovery in My Account. Validation, synchronous one-attempt admission, the name-only payload, captured application/Firestore/identity/UID context, the update, the required authoritative profile reread, current-attempt checks, the fixed message, the existing **Try profile again** action, exact service-call counts, and AUTH-006G confirmed-success behavior remain unchanged. After validation and exact save admission, the existing pending Save-focus token may contain only the opaque current profile generation and attempt ID, and only while the exact connected **Save** button owns focus. If that exact current update rejects, or if its required reread returns `null` or rejects, the current catch creates a minimal unconfirmed-result token for the current generation and attempt and transfers a matching pending focus token only to the separate unconfirmed focus ref before it clears the profile, editor, and success confirmation and enters the existing unavailable recovery. This never infers whether the update persisted. The reused alert says exactly **We could not confirm your profile change. Try the profile again before making another change.** and contains no member, profile, provider, or caught detail. Only the exact current save-unconfirmed state gives that alert `role="alert"`, assertive live, atomic, programmatic-focus, 100-percent width/minimum-zero/maximum-100-percent wrapping, and scoped 3-pixel `#005bd8` focus-outline semantics. Its message immediately precedes the enabled existing **Try profile again** button, so the alert announces uncertainty before the retry action and the button remains the next Tab stop. One layout effect consumes the matching intent before every target check. It requires the exact current generation and attempt, unavailable state, no profile, editor, or success confirmation, the byte-exact unconfirmed message, and the connected alert. It leaves an already-focused alert alone, restores focus only from no active element, the document body, the document root, or a disconnected element, and preserves every other connected focus deliberately chosen while the update or reread is pending. A valid unfocused or programmatic Save still shows the same unconfirmed recovery without moving focus, including after its outside focus origin disappears. Initial setup or profile-read failure, validation failure, application/Firestore/identity/UID or generation change, a newer attempt, obsolete update or reread settlement, reload, unmount, and later rerender cannot focus a stale unconfirmed result; **Try profile again** clears every old Save intent before starting its existing load. The handoff adds no read, write, request, retry, provider call, log, or stored value. It reuses the existing alert and retry control, adds no page node or data movement, and changes no service contract, permission, ownership, Function, Rule, schema, index, package, workflow, provider configuration, account, sign-in state, production data, deployment, publication, membership, dues, role, payment, entitlement, roster, photo query, facial recognition, matching, embedding, similarity, biometric processing, or live behavior. Settlement after the later **Try profile again** action, validation error association, Edit-to-input focus, and Cancel-to-Edit focus remain separate. 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 diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 8abe9b3..cf19ce4 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -1075,15 +1075,16 @@ flowchart LR 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 -- "No" --> Unconfirmed["Show the fixed unconfirmed alert\nRestore otherwise-lost focus only if Save owned it"] 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 + Confirm -- "No" --> Unconfirmed + Unconfirmed --> Retry["Try profile again"] 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 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. +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 update rejection or missing or rejected reread shows the fixed unconfirmed alert, restores otherwise-lost focus to that alert only when Save owned focus, and keeps **Try profile again** as the next Tab stop; the reviewed Rules deny a browser phone change. Officer steps after every prerequisite has proof: @@ -1210,7 +1211,7 @@ Officer source-review procedure for AUTH-006G [#651] confirmed profile-name Save 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. +97. Confirm AUTH-006H [#653](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/653) separately owns unconfirmed-save alert focus. 98. Confirm validation error association remains separate. 99. Confirm Edit-to-input focus remains separate. 100. Confirm Cancel-to-Edit focus remains separate. @@ -1252,6 +1253,149 @@ Officer source-review procedure for AUTH-006G [#651] confirmed profile-name Save **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. +Officer source-review procedure for AUTH-006H [#653] unconfirmed profile-name Save result focus — source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify from specialist-prepared evidence that an exact current unconfirmed full-name Save keeps the existing fixed alert and **Try profile again** action, and that only a Save which owned focus may return otherwise-lost focus to that alert, without guessing whether the update persisted, using a real account, changing Firebase, or publishing the website. + +**Approvers:** membership lead, identity/privacy owner, and platform/security owner. + +**Prerequisites:** released #651/PR #652 supplies the confirmed-save result and the existing generation-and-attempt focus token; released #531/PR #534 supplies the one-attempt and account-context fences. Ask the platform owner or testing specialist for the exact #653 source candidate, the trustworthy unchanged-runtime named failure, the complete passing AUTH-006H named result, the full Account result, type-check and scoped-lint results, the bounded alert-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 unconfirmed profile-name result focus marked **SOURCE ONLY, NOT LIVE**. +2. Ask the platform owner for the exact #653 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-only diff digest `6e9d9a8da90d1a0c22144d7e64b27fdb05735893177393ae74bd5e3ff18e6170`. +7. Ask the platform owner for combined three-path digest `b84d60f1166e924062d65e7a9834d9a94a567098561e9e4ba955d6f3f3b1350b`. +8. Ask the testing specialist for the trustworthy unchanged-runtime result with one expected failure and 287 skipped tests. +9. Ask the testing specialist for test-only RED digest `25ca6ccd34dcd0acbef28727d05f05f72079ed6bd55ee0ae3b11b43c04a4f056`. +10. Ask the testing specialist for the green 23-of-23 AUTH-006H result. +11. Ask the testing specialist for the green 288-of-288 full Account result. +12. Ask the testing specialist for the passing type-check result. +13. Ask the testing specialist for the passing scoped ESLint result. +14. Ask the testing specialist for the passing diff-check result. +15. Ask the testing specialist for the bounded wrapping and focus-style evidence. +16. Ask the testing specialist for final test-diff digest `895cb6ef708ed0714d53a0cf2dc7b7336104cf111ab2d5f591078051772d81ea`. +17. Ask the testing specialist for the redacted written synthetic-behavior report. +18. Confirm the report names the specialist who ran the checks. +19. Confirm the backup officer used no terminal. +20. Confirm every profile and account in the evidence is made up. +21. Confirm no real name, email, UID, profile, or screenshot appears in the evidence. +22. Confirm no production Firebase call appears in the evidence. +23. Confirm the alert says exactly **We could not confirm your profile change. Try the profile again before making another change.** +24. Confirm the alert contains no member, profile, provider, response, or caught-error detail. +25. Confirm the exact current save-unconfirmed result has alert semantics. +26. Confirm the exact current save-unconfirmed result has assertive live semantics. +27. Confirm the exact current save-unconfirmed result has atomic semantics. +28. Confirm the exact current save-unconfirmed result has a programmatic-only tab position of `-1`. +29. Confirm the exact current save-unconfirmed result uses the scoped `account-profile__unconfirmed-result` class. +30. Confirm that scoped class uses border-box sizing. +31. Confirm that scoped class uses 100-percent width. +32. Confirm that scoped class uses zero minimum width. +33. Confirm that scoped class uses 100-percent maximum width. +34. Confirm that scoped class wraps overflow anywhere. +35. Confirm focused-alert evidence shows a 3-pixel outline. +36. Confirm focused-alert evidence shows outline color `#005bd8`. +37. Confirm focused-alert evidence shows a 3-pixel outline offset. +38. Confirm the new focus style does not target every alert on the page. +39. Confirm the fixed message paragraph immediately precedes **Try profile again**. +40. Confirm **Try profile again** remains enabled. +41. Confirm **Try profile again** remains the next Tab stop after the focused alert. +42. Confirm otherwise-lost focus goes to the alert instead of the retry button. +43. Confirm validation finishes before a Save-focus token can be created. +44. Confirm synchronous one-attempt admission finishes before a Save-focus token can be created. +45. Confirm the exact connected Save button must own focus to create the token. +46. Confirm the token records only the current profile generation. +47. Confirm the token records only the exact attempt ID. +48. Confirm the unconfirmed state and focus refs record no member or service detail. +49. Confirm an exact current update rejection transfers only the matching pending focus token. +50. Confirm an update rejection makes one update call and no confirmation reread. +51. Confirm an exact current `null` confirmation reread transfers only the matching pending focus token. +52. Confirm an exact current rejected confirmation reread transfers only the matching pending focus token. +53. Confirm each confirmation-reread path makes one update and one reread. +54. Confirm a caught update or reread detail never reaches the alert. +55. Confirm an unconfirmed path shows no **Profile name saved.** result. +56. Confirm the unconfirmed result does not claim that the update failed or persisted. +57. Confirm only the exact current catch may transfer the matching pending focus token. +58. Confirm one layout effect consumes the transferred token before target checks. +59. Confirm the current profile generation must match before focus. +60. Confirm the current attempt ID must match before focus. +61. Confirm the page must be in the unavailable profile state before focus. +62. Confirm no profile may be present before focus. +63. Confirm no profile editor may be present before focus. +64. Confirm no save-success confirmation may be present before focus. +65. Confirm the profile error must equal the fixed unconfirmed message before focus. +66. Confirm the exact alert must still be connected before focus. +67. Confirm an alert that already retained focus is not focused again. +68. Confirm absent focus returns to the alert. +69. Confirm document-body focus returns to the alert. +70. Confirm document-root focus returns to the alert. +71. Confirm disconnected focus returns to the alert. +72. Confirm connected focus deliberately chosen while the update is pending remains focused. +73. Confirm connected focus deliberately chosen while the reread is pending remains focused. +74. Confirm a consumed token cannot focus the alert after that deliberate target disappears. +75. Confirm a valid unfocused Save still shows the fixed unconfirmed alert. +76. Confirm a valid unfocused Save moves no focus. +77. Confirm removing an unfocused Save's outside origin causes no delayed focus. +78. Confirm a later same-context rerender causes no delayed focus. +79. Confirm an initial profile-setup failure never focuses its generic alert. +80. Confirm an initial profile-read failure never focuses its generic alert. +81. Confirm validation failure keeps Save focused and starts no write. +82. Confirm **Try profile again** clears the old Save-focus token before loading. +83. Confirm **Try profile again** cannot reuse the old unconfirmed result on a later render. +84. Confirm an application-only change makes an old unconfirmed result inert. +85. Confirm a Firestore-service-only change makes an old unconfirmed result inert. +86. Confirm an identity-service-only change makes an old unconfirmed result inert. +87. Confirm a UID-only change makes an old unconfirmed result inert. +88. Confirm unavailable-to-same-context return invalidates the older generation. +89. Confirm a newer Save attempt cannot be disturbed or unlocked by an older rejection. +90. Confirm a stale update rejection cannot show or focus an old unconfirmed result. +91. Confirm a stale authoritative-reread rejection cannot show or focus an old unconfirmed result. +92. Confirm unmount makes a pending unconfirmed result and focus inert. +93. Confirm one admitted current Save still makes exactly one update call. +94. Confirm update rejection still makes no confirmation reread. +95. Confirm a missing or rejected confirmation still makes exactly one reread. +96. Confirm AUTH-006G confirmed-success copy and focus remain unchanged. +97. Confirm AUTH-006F one-attempt and context fences remain unchanged. +98. Confirm unconfirmed-result focus creates no additional read. +99. Confirm unconfirmed-result focus creates no additional write. +100. Confirm unconfirmed-result focus creates no request or retry. +101. Confirm unconfirmed-result focus creates no provider call, log, or stored value. +102. Confirm the source diff changes no Account service or profile payload. +103. Confirm the source diff changes no Function or Firestore Rule. +104. Confirm the source diff changes no schema, index, package, or workflow. +105. Confirm the source diff changes no provider configuration, account, sign-in state, or production data. +106. Confirm the source diff adds no membership, dues, role, payment, entitlement, or roster claim. +107. Confirm the source diff adds no photo query, facial recognition, matching, embedding, similarity, or biometric processing. +108. Confirm active #616 Strava runtime files remain unchanged. +109. Confirm active #616 OAUTH-001A2L/RISK-024 documentation remains unchanged. +110. Confirm the optional-directory availability value remains byte-for-byte `false`. +111. Confirm the last verified production directory deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +112. Confirm #507 still owns every optional-directory connection and live-proof gate. +113. Record the source change as its own state. +114. Record the named test results as their own state. +115. Record whether the change merged as its own state. +116. Record whether any website artifact was published as its own state. +117. Record the exact `runmprc.com` revision as its own state. +118. Record whether Firebase was deployed as its own state. +119. Record whether an outside provider was configured as its own state. +120. Record whether an account or sign-in state changed as its own state. +121. Record whether production data changed as its own state. +122. Record whether the unconfirmed profile-name result focus is live as its own state. +123. Record whether connected profile-photo or officer-finder behavior is live as its own state. +124. Stop before changing Firebase, a provider, an account, production data, directory availability, or the live website. + +**Expected result:** an exact current update rejection, `null` confirmation reread, or rejected confirmation reread keeps the existing fixed unconfirmed alert and enabled **Try profile again** action. Every exact current unconfirmed result records only its generation and attempt ID to own that result. Only an admitted Save that owned focus records a pending focus token. The exact current catch transfers that pending token only when it matches the result. One layout effect consumes it before target checks and requires the matching generation and attempt, unavailable state, no profile, editor, or success confirmation, the exact message, and a connected alert. The alert has assertive, atomic, programmatic-focus, bounded-wrapping, and scoped visible-outline semantics. Its message immediately precedes the retry action. Absent, body, document-root, or disconnected focus returns to the alert; retained alert focus is left alone; and any other connected focus is preserved. An unfocused Save still shows the recovery without moving focus. Initial setup/read failure, validation, reload, context or generation change, newer attempt, stale update/reread work, unmount, and later rerender cannot focus stale recovery. Try clears the old intent before loading. The handoff creates no extra read, write, request, retry, provider call, log, or stored value. It does not claim whether the update persisted. AUTH-006G success 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; unconfirmed copy other than **We could not confirm your profile change. Try the profile again before making another change.**; an alert containing a member, provider, response, or caught detail; missing alert, assertive, atomic, programmatic-focus, bounded-wrapping, or visible-outline semantics; the retry action appearing before the message or not remaining the next Tab stop; focus sent directly to the retry action; a focused exact current Save whose unconfirmed alert receives no otherwise-lost focus; an unfocused Save that moves focus; a connected control that loses deliberately selected focus; a token containing anything beyond generation and attempt ID; transfer outside an exact current catch; a browser claim that the write failed or persisted; delayed focus on a later render; stale application, Firestore, identity, UID, generation, attempt, update, reread, reload, or unmounted work that shows or focuses recovery; an extra read, write, request, retry, provider call, log, or stored value; changed validation, payload, AUTH-006F, AUTH-006G success, Try-profile-again result focus, validation error association, 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 #653 issue, reviewed pull request and commit; trustworthy unchanged-runtime result with one expected failure and 287 skipped tests plus test-only RED digest `25ca6ccd34dcd0acbef28727d05f05f72079ed6bd55ee0ae3b11b43c04a4f056`; green 23-of-23 AUTH-006H block; green 288-of-288 Account suite; passing type-check, scoped ESLint, and diff-check; bounded wrapping and focus-style evidence; runtime-only digest `6e9d9a8da90d1a0c22144d7e64b27fdb05735893177393ae74bd5e3ff18e6170`; final test-diff digest `895cb6ef708ed0714d53a0cf2dc7b7336104cf111ab2d5f591078051772d81ea`; combined three-path digest `b84d60f1166e924062d65e7a9834d9a94a567098561e9e4ba955d6f3f3b1350b`; 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 #653 changes source only. An undo must preserve AUTH-006F context fencing, AUTH-006G confirmed-save behavior, the fixed generic unconfirmed 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, the alert exposed a caught or provider value, focus crossed application or account contexts, a stale result appeared, deliberately selected connected focus was stolen, the browser claimed whether an unconfirmed write persisted, 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 40983f5..7aa2f24 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -42,6 +42,19 @@ outline-offset: 3px; } +.account-profile__unconfirmed-result { + box-sizing: border-box; + width: 100%; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; +} + +.account-profile__unconfirmed-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 5b2c989..2c0bbdb 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -987,6 +987,486 @@ describe('Account profile recovery', () => { }); }); + describe('AUTH-006H profile-save unconfirmed-result focus', () => { + const UNCONFIRMED_MESSAGE = 'We could not confirm your profile change. Try the profile again before making another change.'; + const privateUpdateCanary = 'synthetic-private-update-rejection'; + + const servicesA = { + firebaseResources: { app, firestore }, + identityService: { signOut, resendVerificationEmail }, + }; + const userB = { + uid: 'synthetic-user-b', + email: 'member-b@example.test', + role: 'unverified' as const, + }; + const appB = { name: 'synthetic-app-b' }; + const firestoreB = { name: 'synthetic-firestore-b' }; + const identityServiceB = { + signOut: jest.fn(), + resendVerificationEmail: jest.fn(), + }; + const profileB = { + ...PROFILE, + uid: userB.uid, + email: userB.email, + fullName: 'Current Synthetic Member B', + }; + + function useAccountServices(services: typeof servicesA | null) { + (useServiceLocator as jest.Mock).mockReturnValue({ + services, + isReady: Boolean(services), + }); + } + + function evictFocusToBody() { + const displaced = document.createElement('button'); + document.body.append(displaced); + displaced.focus(); + displaced.remove(); + expect(document.body).toHaveFocus(); + } + + function getUnconfirmedAlert() { + const alert = screen.getByRole('alert'); + const message = alert.querySelector('p'); + const retry = screen.getByRole('button', { name: 'Try profile again' }); + expect(message?.textContent).toBe(UNCONFIRMED_MESSAGE); + expect(alert).toContainElement(retry); + expect(message?.nextElementSibling).toBe(retry); + expect(retry).toBeEnabled(); + expect(retry).not.toHaveAttribute('tabindex', '-1'); + return alert; + } + + async function startDeferredUpdate({ + focusSave = true, + fullName = 'Unconfirmed Synthetic Member', + }: { + focusSave?: boolean; + fullName?: string; + } = {}) { + 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' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: fullName }, + }); + const save = screen.getByRole('button', { name: 'Save' }); + if (focusSave) save.focus(); + fireEvent.click(save); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + return { + fullName, save, update, view, + }; + } + + async function startDeferredConfirmation() { + const confirmation = accountDeferred(); + (updateMyProfile as jest.Mock).mockResolvedValueOnce(undefined); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockReturnValueOnce(confirmation.promise); + 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)); + return { + confirmation, save, view, + }; + } + + test('focuses the exact current unconfirmed alert after a focused Save loses native focus', async () => { + const { fullName, save, update } = await startDeferredUpdate(); + expect(save).toHaveFocus(); + evictFocusToBody(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + const alert = getUnconfirmedAlert(); + expect(alert).toHaveFocus(); + expect(alert).toHaveAttribute('role', 'alert'); + expect(alert).toHaveAttribute('aria-live', 'assertive'); + expect(alert).toHaveAttribute('aria-atomic', 'true'); + expect(alert).toHaveAttribute('tabindex', '-1'); + expect(alert).toHaveClass('account-profile__unconfirmed-result'); + expect(alert.isConnected).toBe(true); + expect(screen.queryByText('Profile name saved.')).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent(privateUpdateCanary); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(updateMyProfile).toHaveBeenCalledWith( + firestore, + USER.uid, + { fullName }, + ); + expect(getMyProfile).toHaveBeenCalledTimes(1); + }); + + test.each([ + ['missing', 'resolve' as const, null], + [ + 'rejected', + 'reject' as const, + new Error('synthetic-private-confirmation-rejection'), + ], + ])('focuses the same alert when the current confirmation read is %s', async ( + _label, + settlement, + outcome, + ) => { + const { confirmation } = await startDeferredConfirmation(); + evictFocusToBody(); + + await act(async () => { + if (settlement === 'resolve') { + confirmation.resolve(outcome as null); + } else { + confirmation.reject(outcome); + } + }); + + const alert = getUnconfirmedAlert(); + expect(alert).toHaveFocus(); + 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([ + ['no active element', null], + ['the document root', document.documentElement], + ['a disconnected element', document.createElement('button')], + ])('restores unconfirmed-result focus from %s', async (_label, lostFocus) => { + const { update } = await startDeferredUpdate(); + const activeElement = jest.spyOn(document, 'activeElement', 'get') + .mockImplementation(() => lostFocus); + try { + await act(async () => update.reject(new Error(privateUpdateCanary))); + } finally { + activeElement.mockRestore(); + } + + expect(getUnconfirmedAlert()).toHaveFocus(); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(1); + }); + + test('consumes the armed intent after preserving deliberate update-pending focus', async () => { + const { update, view } = await startDeferredUpdate(); + const deliberateTarget = document.createElement('button'); + document.body.append(deliberateTarget); + deliberateTarget.focus(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + const alert = getUnconfirmedAlert(); + expect(alert).not.toHaveFocus(); + expect(deliberateTarget).toHaveFocus(); + deliberateTarget.remove(); + expect(document.body).toHaveFocus(); + view.rerender(accountView()); + expect(alert).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + }); + + test('preserves connected deliberate focus while the confirmation read rejects', async () => { + const { confirmation } = await startDeferredConfirmation(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + + await act(async () => confirmation.reject( + new Error('synthetic-private-confirmation-rejection'), + )); + + expect(getUnconfirmedAlert()).not.toHaveFocus(); + expect(deliberateTarget).toHaveFocus(); + }); + + test('leaves an already-focused unconfirmed alert alone', async () => { + const { update } = await startDeferredUpdate(); + 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__unconfirmed-result', + )); + try { + await act(async () => update.reject(new Error(privateUpdateCanary))); + } finally { + activeElement.mockRestore(); + focus.mockRestore(); + } + + const alert = getUnconfirmedAlert(); + expect(focusTargets).not.toContain(alert); + }); + + test('shows an unfocused programmatic result without delayed focus after its origin disappears', async () => { + const outsideOrigin = document.createElement('button'); + document.body.append(outsideOrigin); + outsideOrigin.focus(); + const { update, view } = await startDeferredUpdate({ focusSave: false }); + expect(outsideOrigin).toHaveFocus(); + outsideOrigin.remove(); + expect(document.body).toHaveFocus(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + expect(getUnconfirmedAlert()).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + view.rerender(accountView()); + expect(getUnconfirmedAlert()).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + }); + + test.each([ + ['setup', 'setup'], + ['read', 'read'], + ])('does not move focus from body for an initial profile %s failure', async ( + _label, + stage, + ) => { + evictFocusToBody(); + if (stage === 'setup') { + (ensureMyProfile as jest.Mock).mockRejectedValueOnce( + new Error('synthetic-private-setup-rejection'), + ); + } else { + (getMyProfile as jest.Mock).mockRejectedValueOnce( + new Error('synthetic-private-read-rejection'), + ); + } + + renderAccount(); + + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent( + 'Your profile is temporarily unavailable.', + ); + expect(alert).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + expect(document.body).not.toHaveTextContent(/synthetic-private/i); + }); + + test('keeps validation failure local with Save focused and starts no 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(save).toHaveFocus(); + expect(updateMyProfile).not.toHaveBeenCalled(); + expect(getMyProfile).toHaveBeenCalledTimes(1); + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + }); + + test('clears the old save intent before Try profile again reloads', async () => { + const { update, view } = await startDeferredUpdate(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + await act(async () => update.reject(new Error(privateUpdateCanary))); + const oldAlert = getUnconfirmedAlert(); + const retry = screen.getByRole('button', { name: 'Try profile again' }); + retry.focus(); + + fireEvent.click(retry); + + expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument(); + expect(oldAlert.isConnected).toBe(false); + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + view.rerender(accountView()); + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test.each([ + [ + 'UID', + servicesA, + userB, + ], + [ + 'Firebase app', + { + firebaseResources: { app: appB, firestore }, + identityService: servicesA.identityService, + }, + USER, + ], + [ + 'Firestore service', + { + firebaseResources: { app, firestore: firestoreB }, + identityService: servicesA.identityService, + }, + USER, + ], + [ + 'identity service', + { + firebaseResources: { app, firestore }, + identityService: identityServiceB, + }, + USER, + ], + ])('keeps a stale rejected update inert after a %s-only change', async ( + _transition, + nextServices, + nextUser, + ) => { + useAccountServices(servicesA); + const { update, view } = await startDeferredUpdate(); + const currentProfile = { + ...PROFILE, + uid: nextUser.uid, + email: nextUser.email, + fullName: `Current ${_transition} Context`, + }; + (getMyProfile as jest.Mock).mockResolvedValueOnce(currentProfile); + useAccountServices(nextServices); + view.rerender(accountView(nextUser)); + expect(await screen.findByText(currentProfile.fullName)).toBeInTheDocument(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + expect(deliberateTarget).toHaveFocus(); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('invalidates a failure focus intent across unavailable and same-context return', async () => { + useAccountServices(servicesA); + const { update, view } = await startDeferredUpdate(); + const restoredProfile = { + ...PROFILE, + fullName: 'Restored Same Context Member', + }; + useAccountServices(null); + view.rerender(accountView()); + expect(screen.getByRole('status')).toHaveTextContent('Loading profile...'); + (getMyProfile as jest.Mock).mockResolvedValueOnce(restoredProfile); + useAccountServices(servicesA); + view.rerender(accountView()); + expect(await screen.findByText(restoredProfile.fullName)).toBeInTheDocument(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + expect(deliberateTarget).toHaveFocus(); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('does not let a stale rejected update disturb or unlock a newer attempt', async () => { + const oldUpdate = accountDeferred(); + const currentUpdate = accountDeferred(); + (updateMyProfile as jest.Mock) + .mockReturnValueOnce(oldUpdate.promise) + .mockReturnValueOnce(currentUpdate.promise); + useAccountServices(servicesA); + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(profileB); + const view = renderAccount(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + const oldSave = screen.getByRole('button', { name: 'Save' }); + oldSave.focus(); + fireEvent.click(oldSave); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(1)); + + useAccountServices({ + firebaseResources: { app: appB, firestore: firestoreB }, + identityService: identityServiceB, + }); + view.rerender(accountView(userB)); + expect(await screen.findByText(profileB.fullName)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + const currentSave = screen.getByRole('button', { name: 'Save' }); + currentSave.focus(); + fireEvent.click(currentSave); + await waitFor(() => expect(updateMyProfile).toHaveBeenCalledTimes(2)); + evictFocusToBody(); + + await act(async () => oldUpdate.reject(new Error(privateUpdateCanary))); + + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled(); + expect(document.body).toHaveFocus(); + + await act(async () => currentUpdate.reject(new Error('current-private-rejection'))); + + expect(getUnconfirmedAlert()).toHaveFocus(); + expect(document.body).not.toHaveTextContent('current-private-rejection'); + expect(getMyProfile).toHaveBeenCalledTimes(2); + }); + + test('keeps a stale rejected authoritative read inert after a UID change', async () => { + useAccountServices(servicesA); + const { confirmation, view } = await startDeferredConfirmation(); + (getMyProfile as jest.Mock).mockResolvedValueOnce(profileB); + view.rerender(accountView(userB)); + expect(await screen.findByText(profileB.fullName)).toBeInTheDocument(); + const deliberateTarget = screen.getByRole('button', { name: 'Sign out' }); + deliberateTarget.focus(); + + await act(async () => confirmation.reject( + new Error('synthetic-private-stale-confirmation-rejection'), + )); + + expect(screen.queryByText(UNCONFIRMED_MESSAGE)).not.toBeInTheDocument(); + expect(deliberateTarget).toHaveFocus(); + expect(updateMyProfile).toHaveBeenCalledTimes(1); + expect(getMyProfile).toHaveBeenCalledTimes(3); + }); + + test('makes an unmounted focused rejection inert', async () => { + const { update, view } = await startDeferredUpdate(); + const focus = jest.spyOn(HTMLElement.prototype, 'focus'); + view.unmount(); + + await act(async () => update.reject(new Error(privateUpdateCanary))); + + expect(document.body).not.toHaveTextContent(UNCONFIRMED_MESSAGE); + expect(focus).not.toHaveBeenCalled(); + expect(getMyProfile).toHaveBeenCalledTimes(1); + focus.mockRestore(); + }); + + test('uses bounded wrapping and scoped visible-focus CSS for the unconfirmed alert', () => { + const css = readFileSync(join(__dirname, 'Account.css'), 'utf8'); + + expect(css).toMatch(/\.account-profile__unconfirmed-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__unconfirmed-result:focus\s*\{[^}]*outline:\s*3px\s+solid\s+#005bd8;[^}]*outline-offset:\s*3px;[^}]*\}/); + expect(css).not.toMatch(/\[role=(?:['"])?alert(?:['"])?\]\s*:focus/); + }); + }); + 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 4b47126..af5b70d 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -259,6 +259,10 @@ export function AccountContent({ attemptId: number; generation: number; } | null>(null); + const [profileSaveUnconfirmed, setProfileSaveUnconfirmed] = useState<{ + attemptId: number; + generation: number; + } | null>(null); const [regsData, setRegsData] = useState(null); const [regsLoading, setRegsLoading] = useState(true); @@ -294,7 +298,9 @@ export function AccountContent({ const profileSaveBlockedRef = useRef(false); const pendingProfileSaveFocusIntentRef = useRef(null); const profileSaveResultFocusIntentRef = useRef(null); + const profileSaveUnconfirmedFocusIntentRef = useRef(null); const profileSaveResultRef = useRef(null); + const profileSaveUnconfirmedResultRef = useRef(null); const profileSaveButtonRef = useRef(null); const profileContextGenerationRef = useRef(0); const profileContextMountedRef = useRef(false); @@ -329,8 +335,10 @@ export function AccountContent({ setEditing(false); setSaveError(null); setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(null); pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; return () => { profileContextMountedRef.current = false; @@ -338,6 +346,7 @@ export function AccountContent({ profileSaveBlockedRef.current = true; pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; }; }, [firebaseApp, firebaseFirestore, identityService, user.uid]); @@ -380,8 +389,10 @@ export function AccountContent({ setEditing(false); setSaveError(null); setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(null); pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; try { await ensureMyProfile(activeServices.firebaseResources.app); @@ -462,7 +473,9 @@ export function AccountContent({ if (!validation.valid) { pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(null); setSaveError(validation.message); return; } @@ -497,7 +510,9 @@ export function AccountContent({ ? focusToken : null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(null); setSaving(true); setSaveError(null); try { @@ -517,6 +532,8 @@ export function AccountContent({ const confirmation = { attemptId, generation: attempt.generation }; const pendingFocusIntent = pendingProfileSaveFocusIntentRef.current; pendingProfileSaveFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; + setProfileSaveUnconfirmed(null); profileSaveResultFocusIntentRef.current = matchesProfileSaveToken( pendingFocusIntent, confirmation, @@ -527,9 +544,18 @@ export function AccountContent({ setEditing(false); } catch { if (!isCurrentProfileSave(attempt)) return; + const unconfirmed = { attemptId, generation: attempt.generation }; + const pendingFocusIntent = pendingProfileSaveFocusIntentRef.current; pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = matchesProfileSaveToken( + pendingFocusIntent, + unconfirmed, + ) + ? unconfirmed + : null; setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(unconfirmed); setProfile(null); setEditing(false); setSaveError(null); @@ -624,6 +650,41 @@ export function AccountContent({ target.focus(); }, [editing, profile, profileSaveConfirmation, profileState]); + useLayoutEffect(() => { + const focusIntent = profileSaveUnconfirmedFocusIntentRef.current; + profileSaveUnconfirmedFocusIntentRef.current = null; + if (!focusIntent) return; + if (!matchesProfileSaveToken(profileSaveUnconfirmed, focusIntent)) return; + if ( + profileContextGenerationRef.current !== focusIntent.generation + || profileSaveAttemptIdRef.current !== focusIntent.attemptId + || profileState !== 'unavailable' + || profile + || editing + || profileSaveConfirmation + || profileError !== PROFILE_CHANGE_UNCONFIRMED_MESSAGE + ) return; + + const target = profileSaveUnconfirmedResultRef.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, + profileError, + profileSaveConfirmation, + profileSaveUnconfirmed, + profileState, + ]); + const currentSignOutOutcome = signOutOutcome && signOutOutcome.uid === user.uid && signOutOutcome.identityService === identityService @@ -642,6 +703,19 @@ export function AccountContent({ && profileSaveConfirmation.attemptId === profileSaveAttemptIdRef.current ? profileSaveConfirmation : null; + const currentProfileSaveUnconfirmed = profileSaveUnconfirmed + && profileSaveUnconfirmed.generation === profileContextGenerationRef.current + && profileSaveUnconfirmed.attemptId === profileSaveAttemptIdRef.current + ? profileSaveUnconfirmed + : null; + const isCurrentProfileSaveUnconfirmed = Boolean( + currentProfileSaveUnconfirmed + && profileState === 'unavailable' + && !profile + && !editing + && !currentProfileSaveConfirmation + && profileError === PROFILE_CHANGE_UNCONFIRMED_MESSAGE, + ); const registrationsBelongToCurrentContext = registrationsContext && registrationsContext.uid === user.uid && registrationsContext.identityService === identityService @@ -771,7 +845,9 @@ export function AccountContent({ onClick={() => { pendingProfileSaveFocusIntentRef.current = null; profileSaveResultFocusIntentRef.current = null; + profileSaveUnconfirmedFocusIntentRef.current = null; setProfileSaveConfirmation(null); + setProfileSaveUnconfirmed(null); setEditing(true); }} className="account-profile__edit text-sm text-blue-600 hover:underline" @@ -782,13 +858,30 @@ export function AccountContent({ {profileError && (

{profileError}