From b1cb1f65ab0793d492d5b042d1c54b7ef712b1a7 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 14:15:30 -0700 Subject: [PATCH] Harden People finder result clearing (#631) --- IMPLEMENTATION_PLAN.md | 2 + SECURITY.md | 1 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 99 +++++++- src/pages/account/Account.css | 42 ++++ .../members/AdminMemberDirectory.test.tsx | 215 +++++++++++++++++- .../admin/members/AdminMemberDirectory.tsx | 72 +++++- 7 files changed, 417 insertions(+), 16 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 80c5313..001db34 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -175,6 +175,8 @@ Exit gate: **MEMBERS-DIRECTORY-001F current source boundary:** [#629](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/629) changes only the preserved connected Account photo controls. A valid selected JPG, PNG, or WebP is validated and read locally into a centered-square component-memory draft labeled **Selected photo — not uploaded yet**. Selecting, reading, previewing, or cancelling creates no request ID and sends nothing. Only **Save profile photo** creates the request ID and sends the existing revisioned upload request. Confirmed Save clears the draft after refetch. Request-ID failure or a definitive rejection with successful refetch retains a retryable draft; the retry uses the refreshed revision. Unknown upload/readback state or failed rejection readback discards the draft and hides controls. A visibility save preserves the independent draft and refreshes its later Save revision. The application never reads, retains, renders, logs, sends, or otherwise exposes `file.name`; tests use only opaque generated fixture names. Stale reads and render events are fenced across reselection, Cancel, unmount, application change, and account change. Invalid or unreadable selections retain no draft; an unrenderable draft drops its bytes and cannot be saved. The separate saved thumbnail stays authoritative, with a version-reset **Photo unavailable** fallback and Remove still available if it cannot render. Finder visibility remains independent. The availability value stays `false`, the default branch and live #623 preview remain inert, and #507 retains every backend, privacy, authorization, staging, deployment, connection, publication, and live-proof gate. +**MEMBERS-DIRECTORY-001G current source boundary:** [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) changes only the preserved connected officer People finder. A `null` photo still means **No photo**; a supplied non-null thumbnail that fails browser rendering is removed from the page and becomes the distinct **Photo unavailable** fallback, while a different later photo version gets a fresh render attempt. After completed validation, empty results, fixed failure, or result cards, **Clear search and result cards** clears the query and prior displayed state, announces **Search field and displayed result cards cleared.**, and focuses the persistent name input without creating a request ID or calling the directory service. Clear is not offered while idle or pending and does not cancel work, erase browser memory/cache, reverse an audit, or recall a returned or captured result. Name-only explicit search, visual comparison of voluntary thumbnails, result bounds, guard and stale-result fences remain unchanged; there is no photo query, facial recognition, biometric processing, total, export, or roster authority. The availability value remains `false`, so the default source branch and live #623 preview stay inert. #631 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in, or production data. #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. + ### Phase 5 — End-to-end qualification **Issue:** TEST-001 plus final closure evidence from all prior phases diff --git a/SECURITY.md b/SECURITY.md index 61e7027..e852b99 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -102,6 +102,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Frontend-only containment for RISK-042 | MEMBERS-DIRECTORY-001D [#621](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/621) adds one source-controlled availability boundary that defaults unavailable. The account page and `AdminGuard`-protected People finder render the future layout as visibly described, natively disabled controls. The default path does not mount the connected components, read a file or saved state, accept a name, create a request ID, initialize or call a directory service, or render sample or result cards. Focused tests preserve the connected source behind an explicit seam using only synthetic data. #623 published the equivalent inert interface projection as exact 62-file deploy `6a7e072f8f346b0008510d29`. Protected-layout evidence stays synthetic; completed signed-out public checks proved only the exact revision, normal sign-in/admin guards, and absence of a member-directory request. The manifest is inactive. | This is an accidental-call and misleading-interface containment, not an authorization, privacy, retention, backend, provider, or connected-live-behavior control. A later build must not turn availability on through configuration drift or an unreviewed environment value. #623 changed no Firebase, provider configuration, account, sign-in, or production data. #507 must complete approved notice and backup/removal wording, scoped authorization, protected authority, isolated staging, backend-first Rules/Functions/index deployment and readback, synthetic privacy/race proof, and then a separately reviewed source flip before connected website publication. Until then, no real name or photo may be entered, read, uploaded, searched, or saved. | | Source-only privacy and accessibility containment for RISK-042 | MEMBERS-DIRECTORY-001E [#627](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/627) makes the preserved connected Account branch use the projection's bounded Unicode display-name eligibility before a new opt-in while preserving turn-off for an existing opt-in whose name becomes ineligible. It gives no-photo placeholders image semantics, associates generic photo/setting errors with the affected control, exposes name-search validation state and descriptions, and announces a successful non-empty result without a total. Native controls retain keyboard use, explicit readable foreground/background colors, at least 44-pixel interaction height, and 320-pixel containment. A deferred file read cannot submit or render after the application/user context changes. Generated-only tests cover eligible/ineligible current-name updates, accessibility states, narrow layout/contrast classes, and stale file-read races. | This is source-only frontend hardening behind the unchanged `false` availability value. The live #623 preview remains inert; no backend, Rules, index, service contract, package, workflow, provider, release, account, sign-in, or production-data behavior changes. Accessibility state is not authorization, and a browser eligibility check is not server enforcement. #507 still owns privacy approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Do not use a real name or photo. | | Source-only explicit photo-review containment for RISK-042 | MEMBERS-DIRECTORY-001F [#629](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/629) makes the preserved connected Account branch validate and read a selected JPG, PNG, or WebP locally into a centered-square component-memory draft labeled **Selected photo — not uploaded yet**. Selection, validation, reading, preview, and Cancel create no request ID and call no directory service. Only explicit **Save profile photo** creates the request ID and sends the existing revisioned upload request. Confirmed Save clears the draft after authoritative refetch. Request-ID failure or definitive rejection plus successful refetch retains a retryable draft and adopts the refreshed revision; unknown or failed readback discards the draft and hides controls. A visibility save preserves the draft. The application never reads, retains, renders, logs, sends, or otherwise exposes `file.name`; tests use only opaque generated fixture names. Generation and application/account-context fences prevent an older read or render event from changing or uploading a newer draft. Invalid and unreadable selections retain no draft. Unrenderable bytes and their draft UI are discarded, the fixed error remains associated with the persistent file control, and no Save action is exposed. The authoritative saved thumbnail has a version-reset, byte-free **Photo unavailable** fallback while Remove remains available. | A local preview still places private image bytes in browser memory, and explicit Save does not itself prove an approved notice, scoped authority, backend deployment, or successful processing. Retryable bytes intentionally remain in component memory only for request-ID failure or definitive rejection with successful readback; an unknown outcome clears them. This source-only UI control stays behind the unchanged `false` availability value; the live #623 preview remains inert. It changes no visibility preference or service/server contract. #507 still owns privacy and retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Use only generated non-face test images; do not inspect or upload a real photo. | +| Source-only rendered-result containment for RISK-042 | MEMBERS-DIRECTORY-001G [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) makes the preserved connected People finder distinguish an absent photo from a supplied thumbnail that the browser cannot display. `null` renders **No photo**; a failed non-null render removes the image/data URL from the page and renders **Photo unavailable**; a different later version receives a fresh render attempt. After completed validation, empty results, fixed failure, or result cards, **Clear search and result cards** empties the query, removes the prior message, headings, cards, names, and images from component state and the rendered page, announces **Search field and displayed result cards cleared.**, and focuses the name input. It creates no request ID and makes no directory-service call. Generated-only tests use made-up names and non-face thumbnails to cover the fallbacks, version reset, four clearable outcomes, focus, zero-call behavior, keyboard/44-pixel/contrast/320-pixel layout, and the unchanged unavailable default. | This local rendered-page disposal is not cancellation, browser-memory erasure, cache removal, audit rollback, or recall of a response already returned, seen, or captured. Clear is absent while idle or pending; existing pending, stale-context, guard, projection, cap, and fixed-error behavior remains. The source-controlled availability value stays `false`, and live #623 remains inert. No service/server contract, Firebase, provider, account, sign-in, production data, or live behavior changes. #507 still owns notice/retention approval, scoped authorization, protected authority, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. Never add a photo query, face recognition, embedding, similarity, biometrics, total, export, or roster authority; use no real name or photo. | | Immediate Product-binding containment part of RISK-031 | PAY-PRODUCT-001A is tracked in live [#353](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/353). One dependency-free projection is used by the paid race and Shop checkout paths. A present stored Product link must be an own primitive non-empty string from 1 through 255 JavaScript code units and is copied without trimming, conversion, character restrictions, or `prod_` inference. Only a genuinely missing own field retains the compatibility Product-create path. Before any mapping write, a resolved Product must provide a bounded custom ID, exact Product kind, expected declared mode, and an installed-SDK 2xx response marker. Malformed stored links stop before token, registration/order identifier, Product-link or business-record write, or Stripe work; earlier access and request-count checks and their safety-counter writes may already have run. Malformed created results stop after at most one Product attempt but before mapping, Checkout Session, or business-record writes. | This structural containment does not prove provider origin, Stripe account ownership, intended catalog identity, metadata binding, Product status, price, dispatch, delivery, or reconciliation. A rejected create result may leave an orphaned Product; do not retry automatically. Anonymous clean-missing creation remains concurrency-prone and reachable from public traffic. Complete #113 inventory/disposition, authenticated idempotent catalog synchronization, Product-specific plan/pre-send/result/lost-acknowledgement/reconciliation controls, isolated staging, protected Firebase deployment, and provider proof before live commerce. | | Immediate current-handler containment part of RISK-003 | PAY-SESSION-001A is tracked in live [#357](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/357). The current paid race and Shop handlers build one immutable expectation before the Session call, catch rejection without opening it, and immediately project only a closed installed-SDK result. A result must match declared test/live mode, payment/open/unpaid state, exact cents, USD, buyer email, exact closed metadata, exact callbacks, a mode-compatible bounded Session ID, one canonical HTTPS capability at the hard-coded default `checkout.stripe.com` origin, and an installed-SDK 200 marker. Only copied ID/URL values continue; records store the ID but never the URL. Invalid results create no registration/order record and return one fixed unknown-result message. The website makes rejection or a missing paid URL terminal for that page visit, retains the form, and blocks a second direct handler call. | This is a narrow legacy compatibility stop, not trusted provider/account origin, deterministic business idempotency, dispatch/delivery proof, durable result evidence, payment proof, or adoption of the unused C4 chain. The Session call occurs first, so a rejected result may leave a payable orphan; earlier rate-counter, token/identifier, and lazy Product-mapping effects may remain. Reload, another tab/device, or a scripted caller bypasses the page lock. A configured Stripe custom checkout domain is blocked until #113 and a protected configuration boundary approve it. Complete PAY-002C/D persistence-first sagas, trusted C4 controller/result persistence, reconciliation, protected staging/deployment, provider readback, and exact website/Firebase/live proof. | | Immediate pre-render browser containment part of RISK-003 | PAY-SESSION-001B is tracked in live [#503](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/503). Each current public race and Shop page now keeps one component-local in-memory marker. After existing local checks admit a request, the handler sets that marker synchronously before analytics or its first Checkout promise can settle. A same-action or later submission on that mounted page is inert, including the gap before React renders the existing pending disabled button. Focused actual-route tests prove one service/analytics attempt across same-action and post-render repeats; preserve free-participant, volunteer-without-price-tier, and both paid-link navigation branches; and prove local waiver/native-disabled paths do not consume an attempt. | This browser marker is not a server boundary, durable idempotency key, provider dispatch/result record, business-state lock, payment proof, or reconciliation. It never releases during the mounted page visit because the admitted result must navigate or enter #357's terminal unknown-result state. Existing form controls other than the submit button remain editable. Reload, a remount, another tab/device, a script, or a direct service caller can bypass it. A same-mounted route change instead stays locked, and this slice does not fence an older pending result from that changed route. Complete PAY-002C/D persistence-first deterministic commands, durable replay/reconciliation, protected deployment, and exact website/Firebase/Stripe/live proof. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 2f30f30..8208c3a 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -687,6 +687,8 @@ The preserved connected branch sends nothing while the person types. Search happ **MEMBERS-DIRECTORY-001F explicit photo review — SOURCE ONLY:** [#629](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/629) changes only the preserved connected Account branch. Selecting a bounded JPG, PNG, or WebP validates and reads that file locally, then presents a centered-square component-memory draft labeled **Selected photo — not uploaded yet**. Selection, validation, reading, and preview rendering create no request ID and call no directory service. **Save profile photo** creates the request ID and sends the existing revisioned upload command. Confirmed Save clears the draft after refetching the profile. **Cancel selected photo** discards the draft and sends nothing. A request-ID creation failure retains the ready draft. A definitive upload rejection followed by successful refetch retains it for a retry against the refreshed authoritative revision. An unknown upload result, failed post-upload readback, or failed refetch after definitive rejection discards it and hides the controls. A successful visibility save preserves the independent draft, whose later Save uses the refreshed revision. The application never reads, retains, renders, logs, sends, or otherwise exposes `file.name`; tests use only opaque generated fixture names. Generation and application/account-context fences make an older read or image event inert after reselection, Cancel, unmount, application change, or account change. Invalid and unreadable selections retain no draft. Browser-unrenderable bytes and their draft UI are discarded, the fixed error remains associated with the persistent file control, and no Save action is exposed. The visibly separate **Current saved photo** stays authoritative while a draft is pending; if that processed thumbnail cannot render, the version-scoped **Photo unavailable** fallback appears while Remove remains available, and a new version gets a fresh render attempt. This source slice changes no finder visibility, service contract, Function, Rule, index, package, workflow, backend, release control, provider, account, sign-in state, or production data. Availability remains byte-for-byte `false`, the live #623 inert preview is unchanged, and #507 still owns privacy approval, scoped authorization, staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. +**MEMBERS-DIRECTORY-001G local People-finder disposal — SOURCE ONLY:** [#631](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/631) changes only the preserved connected officer People finder. A result whose photo value is `null` renders **No photo** with the accessible label **No profile photo for <display name>**. A supplied non-null thumbnail that the browser cannot render instead removes that image and its data URL from the rendered page and shows **Photo unavailable** with the accessible label **Profile photo unavailable for <display name>**; a later result with a different photo version receives a fresh render attempt. After completed local validation, an empty result, a fixed search failure, or displayed result cards, **Clear search and result cards** empties the query, clears the prior validation/failure/completion or empty-result display and every heading, card, name, and image, announces **Search field and displayed result cards cleared.**, and focuses the persistent name input. Clear creates no request ID and makes no directory-service call. It is unavailable during an idle or pending search and does not claim to cancel work, erase browser memory or cache, roll back an audit, or recall a response already returned, seen, or captured. Explicit-submit normalization, pending behavior, stale context/unmount fencing, fixed errors, the bounded projection/result cap, and `AdminGuard` remain unchanged. This is still name search plus visual comparison of voluntary thumbnails: there is no photo query, face recognition, embedding, similarity matching, biometric processing, total, export, or roster authority. Availability remains byte-for-byte `false`; the default source branch and live #623 preview remain inert. #631 changes no service contract, Function, Rule, index, package, workflow, backend, provider, account, sign-in state, or production data, and #507 retains every privacy, authorization, staging, deployment, connection, publication, and live-proof gate. + The backend source still has no production index, Rules, Function, privacy-notice, retention/backup approval, or live proof. Synthetic local artifacts and tests prove that the protected #621 layouts are disabled and their default branches make zero directory calls. Publishing and anonymously reading back the exact frontend may prove only its revision, normal sign-in and administrator guards, and absence of a public member-directory request. [#507](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/507) must complete the #110 policy decision, scoped authorization, #133 protected short-lived authority, any required index and backend-first deployment/readback (including the trigger retry policy), isolated synthetic staging checks, and backup-officer procedure before a separately reviewed source change turns availability on. Only then may the connected website be published and verified on `runmprc.com`. #507 must also own an idempotent, dry-runnable projection repair/backfill for any later schema/normalization upgrade, or prove no older preference data exists for the initial target. A green source build, disabled preview, merge, or frontend publication is not authorization to search a real name or inspect a real photo. WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) published only that inert interface as deploy `6a7e072f8f346b0008510d29`, from source `c2d87d1f69f15e128a0bc9b1b9f915b7c8417aec`, tree `411aa6ec9a9459f5d923030533ffc7c007fe6908`, and a 62-file artifact with digest `d837272a1e5efc1575809e87f532276b38d1a63f1dd79ec1aef0533f6da8afb1`. Protected-layout proof remains synthetic. Completed signed-out public proof is limited to the exact revision, normal sign-in and administrator guards, and absence of a member-directory request. The manifest is inactive; the release source is absent; and the rollback ref remains. #623 deployed no Firebase, Rules, Functions, or indexes and changed no provider configuration, account, sign-in, or production data. #507 remains **NOT AVAILABLE YET**. diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index d14de50..6ee0b22 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2440,13 +2440,25 @@ flowchart TD Send --> Readback["Refetch authoritative processed thumbnail or no-photo fallback"] Discard --> Preserved Readback --> Preserved + FinderDisposal["#631 connected People-finder disposal — SOURCE ONLY"] --> Completed["Completed validation, empty, fixed failure, or result-card state"] + Completed --> Clear["Clear query, prior messages/headings/cards/names/images; announce local clear; focus input; zero new request or call"] + FinderDisposal --> PhotoValue{"Returned photo value?"} + PhotoValue -- "null" --> Absent["No photo"] + PhotoValue -- "non-null" --> Render["Browser thumbnail render attempt"] + Render -- "fails" --> Unavailable["Remove image/data URL from page; Photo unavailable"] + Unavailable -. "different later photo version" .-> Fresh["Fresh render attempt"] + Clear --> Preserved + Absent --> Preserved + Unavailable --> Preserved + Fresh --> Preserved + Clear -. "does not cancel, erase, roll back, or recall" .-> Outside["Network work, browser memory/cache, query-free audit, and already seen or captured response"] Preserved -. "NOT LIVE; cannot be mounted until reviewed connection" .-> Later Later["Later #507 reviewed source flip"] --> Gates["Privacy, scoped authorization, staging, and backend-first readback"] Gates --> Connected["Future optional name search with voluntary thumbnails"] Connected -. "never photo search or proof" .-> Official["Membership, role, payment, or official records"] ``` -Text alternative: synthetic local artifacts prove the signed-in Account preview and administrator-guarded People finder with every directory control disabled. #623 published the exact inert artifact. Completed signed-out public checks prove only its exact revision, normal guards, and absence of a directory request, not the protected layouts. #627 preserves accessible connected Account and People finder branches only in source behind the unchanged false availability value. #629 preserves a further source-only photo-review branch: a generated valid image is read locally into a centered-square draft labeled Selected photo — not uploaded yet; Cancel discards it without a request, while Save alone creates a request and sends the existing upload command before authoritative profile readback. The current saved photo remains separate and authoritative. A failed saved-thumbnail render shows a version-scoped Photo unavailable fallback without removing the Remove action. Neither connected-source branch is live or mounted. Only a later #507 source flip after privacy, authorization, staging, and backend-first readback could connect the optional name search and voluntary thumbnails. It will not search a photo, and a result will not prove or change an official record. +Text alternative: the published #623 artifact keeps the Account and administrator-guarded People-finder controls disabled; source-only #627 preserves the accessible connected layouts behind the unchanged false availability value; source-only #629 adds local photo review where Cancel sends nothing, Save alone sends the existing upload command, and the current saved photo remains authoritative with a version-reset unavailable fallback; source-only #631 distinguishes a `null` **No photo** result from an unrenderable supplied **Photo unavailable** result, retries a different later photo version, and lets completed local states clear the query, messages, cards, names, and images with an announcement, input focus, and no new request or service call; that Clear action does not cancel work, erase memory or cache, roll back an audit, or recall a seen result; and only #507 may later connect name search plus voluntary thumbnails after privacy, authorization, staging, and backend-first readback, without photo search, face recognition, or official-record authority. Officer review steps for the #621 frontend preview: @@ -2675,6 +2687,91 @@ Officer review steps for MEMBERS-DIRECTORY-001F [#629] explicit photo review — **Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real photo or filename appeared, any bytes were sent before Save, finder visibility changed, or an unknown upload result could not be reconciled. +Officer source-review procedure for MEMBERS-DIRECTORY-001G [#631] local People-finder disposal — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify that the preserved connected People-finder source truthfully distinguishes no supplied photo from a browser display failure and locally clears completed search displays without creating another request. This review does not connect the feature, inspect a real person, or change production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #629 is reviewed and merged. Ask the platform owner for the exact #631 source candidate, named generated-only test output, and a written synthetic-behavior report prepared by the platform owner or testing specialist. The evidence may use only made-up names such as **Casey Example** and **River Sample** plus generated non-face thumbnails. The backup officer reviews that evidence and does not operate a test harness. Keep the source-controlled availability value `false`. Do not sign in to production, enter a real name, select a real photo, call production Firebase, or change production data. + +1. Keep the complete profile-photo and People-finder feature marked **NOT AVAILABLE YET**. +2. Ask the platform owner for the exact #631 issue. +3. Ask the platform owner for the reviewed pull request. +4. Ask the platform owner for the candidate or merge commit. +5. Ask the platform owner for the named MEMBERS-DIRECTORY-001G test output. +6. Ask the platform owner for the written synthetic-behavior report. +7. Confirm the report names the platform owner or testing specialist who ran the test. +8. Confirm the source-controlled availability value remains byte-for-byte `false`. +9. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +10. Confirm the evidence uses only made-up names. +11. Confirm the evidence uses only generated non-face thumbnails. +12. Confirm the named test keeps the page behind the existing administrator guard. +13. Confirm the named test finds no photo input or photo-query path. +14. Confirm the named test reports **No photo** for a `null` photo value. +15. Confirm the named test reports **No profile photo for Casey Example** as the matching accessible label. +16. Confirm the named test reports **Photo unavailable** after a supplied generated thumbnail fails to render. +17. Confirm the named test reports **Profile photo unavailable for River Sample** as the matching accessible label. +18. Confirm the named test reports that the failed image leaves the rendered page. +19. Confirm the named test reports that the failed image data URL leaves the rendered page. +20. Confirm the named test reports a fresh render attempt for a different later photo version. +21. Confirm the named test reports no Clear action while the page is idle. +22. Confirm the named test reports no Clear action while a search is pending. +23. Confirm the named test reports Clear after an empty result. +24. Confirm the named test reports Clear after local validation. +25. Confirm the named test reports Clear after the fixed search failure. +26. Confirm the named test reports Clear after displayed result cards. +27. Confirm the named test reports that Clear empties the name input. +28. Confirm the named test reports that Clear removes the prior message. +29. Confirm the named test reports that Clear removes the result heading. +30. Confirm the named test reports that Clear removes every displayed card. +31. Confirm the named test reports that Clear removes every displayed made-up name. +32. Confirm the named test reports that Clear removes every displayed generated image. +33. Confirm the named test reports that Clear removes every displayed image data URL. +34. Confirm the named test reports **Search field and displayed result cards cleared.** in the persistent polite status region. +35. Confirm the named test reports that focus returns to the persistent name input. +36. Confirm the named test reports no new request number from Clear. +37. Confirm the named test reports no new directory-service call from Clear. +38. Confirm the named test reports that input editing still clears earlier results. +39. Confirm the named test reports a native keyboard-operable Clear button. +40. Confirm the named test reports at least a 44-pixel Clear-button height. +41. Confirm the specialist's contrast record covers normal, hover, and focus states. +42. Confirm the specialist's narrow-layout record covers a 320-pixel width without horizontal overflow. +43. Confirm the named test reports no directory context in the default unavailable branch. +44. Confirm the named test reports no request number in the default unavailable branch. +45. Confirm the named test reports no directory-service call in the default unavailable branch. +46. Confirm the named test reports that typing alone sends nothing. +47. Confirm the test record keeps the existing pending-search fence green. +48. Confirm the test record keeps the existing application-context fence green. +49. Confirm the test record keeps the existing administrator-context fence green. +50. Confirm the test record keeps the existing unmount fence green. +51. Confirm the source diff retains the existing display-name and optional-thumbnail projection. +52. Confirm the source diff adds no photo query. +53. Confirm the source diff adds no face recognition or biometric processing. +54. Confirm the source diff adds no result total, export, or roster authority. +55. Confirm the source diff changes no service contract, Function, Rule, index, schema, package, workflow, or release control. +56. Record the exact source change as its own state. +57. Record every named test result as its own state. +58. Record whether the change merged as its own state. +59. Record whether any website artifact was published as its own state. +60. Record the exact `runmprc.com` revision as its own state. +61. Record whether Firebase was deployed as its own state. +62. Record whether an outside provider was configured as its own state. +63. Record whether an account or sign-in state changed as its own state. +64. Record whether production data changed as its own state. +65. Record whether connected or live People-finder behavior became available as its own state. +66. Stop before changing availability, Firebase, a provider, an account, production data, or the live website. + +**Expected result:** the reviewed connected source uses **No photo** only for a `null` photo and uses **Photo unavailable** when a supplied generated thumbnail cannot render. The failed image/data URL leaves the rendered page, and a different later photo version receives a fresh attempt. Clear is offered only after completed validation, empty results, fixed failure, or result cards. It removes the query and prior displayed message, headings, cards, names, and images, uses the persistent polite status region to announce **Search field and displayed result cards cleared.**, focuses the persistent input, creates no request number, and makes no directory-service call. It is not available while idle or pending. Availability remains `false`; the default branch and live #623 preview remain inert. The backend and connected behavior remain **NOT AVAILABLE YET**. + +**Stop conditions:** a real name or photo; a face or photo query; production sign-in; direct production Firebase access; Clear offered while idle, pending, or in the disabled preview; a new request number or service call caused by Clear; a failed supplied image that remains in the page or is mislabeled **No photo**; a later photo version that inherits an older failure; a query, prior message, result heading, card, name, image, or image data URL that remains displayed after Clear; missing input focus; an availability flip; a Function, Rule, index, schema, provider, account, sign-in, production-data, or website change; or a claim that local Clear cancels network work, erases browser memory/cache, rolls back the query-free audit, or recalls a result already returned, seen, or captured. + +**Success proof:** record the exact #631 issue, reviewed pull request and commit; green separately named MEMBERS-DIRECTORY-001G focused tests; green full frontend tests; type-checking; diagnostic production build; unchanged lint baseline; workflow checks; diff-check; independent privacy/security, frontend/accessibility, 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 connected/live behavior separately. Record the unchanged `false` availability value and unchanged #623 deploy separately. Source and tests do not prove merge; merge does not prove publication; publication does not prove Firebase, provider, account, data, or connected-live behavior. Final connection and live proof remain #507 work. + +**Undo:** use one reviewed frontend revert or safe roll-forward. Confirm the default disabled branch still makes zero directory calls. No Firebase, provider, account, or production-data undo is needed because #631 changes source only. Undo cannot recall a result already seen or captured and must not alter the query-free audit. + +**Escalation:** membership lead plus privacy and platform/security owners. Use the private incident path if a real name or photo appeared, a supplied image remained exposed after failure or Clear, Clear made a request, connected behavior became available, or the local result could not be removed from the rendered page. + **Expected result:** production deploy `6a7e072f8f346b0008510d29` defaults to a visibly disabled preview that makes zero directory calls, accepts no file or name, and shows no person. Separate synthetic tests prove the protected disabled layouts and preserved connected source. Completed signed-out public readback proves only the exact revision, normal guards, and absence of a member-directory request. The backend and connected behavior remain unavailable. There is no public directory, official roster, public photo URL, Firebase Storage object, photo-as-query path, face recognition, similarity score, embedding, biometric template, export, result total, or pagination. **Stop conditions:** an enabled preview control; a preview that reads saved directory state, accepts a file or finder name, creates a directory request number, calls a directory service, or shows a sample or result card; a real person, name, photo, member record, production sign-in, direct production Firebase access, production data change, or member-directory callable request; a public/permanent photo URL; an upload that silently opts in; a result from a search transaction ordered after completed opt-out; a notice that fails to explain that an earlier-committed response may still arrive; raw image, name, query, result identity, or provider detail in a log, issue, screenshot, message, email, or AI tool; a request for photo search, face recognition, similarity matching, or biometric processing; missing privacy approval for connected publication; an unreviewed availability flip; or a claim that source, tests, merge, frontend publication, or preview means the backend feature is live. diff --git a/src/pages/account/Account.css b/src/pages/account/Account.css index 8cc7f23..5c95aad 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -245,6 +245,35 @@ border-color: #1e3a8a; } +.member-directory-admin__actions { + min-width: 0; + max-width: 100%; +} + +.member-directory-admin__button--secondary { + box-sizing: border-box; + min-height: 2.75rem; + max-width: 100%; + color: #1e3a8a; + background: #fff; + border-color: #1e40af; + font: inherit; + font-weight: 700; + cursor: pointer; + overflow-wrap: anywhere; +} + +.member-directory-admin__button--secondary:hover { + color: #111827; + background: #facc15; + border-color: #111827; +} + +.member-directory-admin__button--secondary:focus-visible { + outline: 3px solid #1e40af; + outline-offset: 3px; +} + .member-directory-admin__message { border: 1px solid; } @@ -463,6 +492,19 @@ border-radius: 0.375rem; } +@media (max-width: 320px) { + .member-directory-admin__actions { + flex-direction: column; + width: 100%; + max-width: 100%; + } + + .member-directory-admin__button--secondary { + width: 100%; + max-width: 100%; + } +} + @media (max-width: 359px) { .member-directory-profile { padding: 0.75rem; diff --git a/src/pages/admin/members/AdminMemberDirectory.test.tsx b/src/pages/admin/members/AdminMemberDirectory.test.tsx index 5b08523..41b3f63 100644 --- a/src/pages/admin/members/AdminMemberDirectory.test.tsx +++ b/src/pages/admin/members/AdminMemberDirectory.test.tsx @@ -39,6 +39,7 @@ jest.mock('../../../components/SEO', () => function SEO() { const REQUEST_ID = '123e4567-e89b-42d3-a456-426614174000'; const ENTRY_REF = `entry_${'a'.repeat(64)}`; const SECOND_ENTRY_REF = `entry_${'b'.repeat(64)}`; +const QUERY_REQUIREMENT_FOR_TEST = 'Enter a longer name prefix using letters or numbers. Normalized search text may contain 2 to 80 characters.'; const app = { name: 'synthetic-app' } as any; const otherApp = { name: 'other-synthetic-app' } as any; const photo = { @@ -48,6 +49,11 @@ const photo = { height: 256 as const, version: REQUEST_ID, }; +const replacementPhoto = { + ...photo, + base64Data: btoa('RIFF1111WEBPreplacement-synthetic-pixels'), + version: '223e4567-e89b-42d3-a456-426614174001', +}; function deferred() { let resolve!: (value: T | PromiseLike) => void; @@ -266,9 +272,9 @@ describe('Admin People finder', () => { pending.resolve({ schemaVersion: 1, results: [] }); await pending.promise; }); - expect(screen.getByRole('status')).toHaveTextContent( + expect(screen.getByText( 'No result cards are available for that name prefix. Try a longer prefix if you expected someone.', - ); + )).toHaveAttribute('role', 'status'); }); test('renders only the voluntary name/photo card fields and falls back on image failure', async () => { @@ -302,8 +308,9 @@ describe('Admin People finder', () => { expect(document.body).not.toHaveTextContent(SECOND_ENTRY_REF); fireEvent.error(image); - expect(screen.getByRole('img', { name: 'No profile photo for Synthetic Runner' })) - .toHaveTextContent('No photo'); + expect(screen.getByRole('img', { + name: 'Profile photo unavailable for Synthetic Runner', + })).toHaveTextContent('Photo unavailable'); }); test('announces non-empty completion politely without exposing a result total', async () => { @@ -470,4 +477,204 @@ describe('Admin People finder', () => { 'The People finder is unavailable right now. No results are shown.', ); }); + + describe('MEMBERS-DIRECTORY-001G clear-results and thumbnail recovery contract', () => { + test('distinguishes absent and unavailable photos and retries a different photo version', async () => { + (searchMemberDirectory as jest.Mock) + .mockResolvedValueOnce({ + schemaVersion: 1, + results: [ + { entryRef: ENTRY_REF, displayName: 'Synthetic Runner', photo }, + { entryRef: SECOND_ENTRY_REF, displayName: 'No Photo Runner', photo: null }, + ], + }) + .mockResolvedValueOnce({ + schemaVersion: 1, + results: [ + { + entryRef: ENTRY_REF, + displayName: 'Synthetic Runner', + photo: replacementPhoto, + }, + ], + }); + renderDirectory(); + + submitSearch('synthetic'); + + const firstImage = await screen.findByRole('img', { + name: 'Profile thumbnail for Synthetic Runner', + }); + expect(screen.getByRole('img', { name: 'No profile photo for No Photo Runner' })) + .toHaveTextContent('No photo'); + fireEvent.error(firstImage); + + expect(screen.queryByRole('img', { + name: 'Profile thumbnail for Synthetic Runner', + })).not.toBeInTheDocument(); + expect(firstImage).not.toBeInTheDocument(); + expect(document.querySelector(`img[src="data:image/webp;base64,${photo.base64Data}"]`)) + .toBeNull(); + expect(screen.getByRole('img', { + name: 'Profile photo unavailable for Synthetic Runner', + })).toHaveTextContent('Photo unavailable'); + expect(screen.queryByRole('img', { + name: 'No profile photo for Synthetic Runner', + })).not.toBeInTheDocument(); + + submitSearch('replacement'); + + const replacementImage = await screen.findByRole('img', { + name: 'Profile thumbnail for Synthetic Runner', + }); + expect(replacementImage).toHaveAttribute( + 'src', + `data:image/webp;base64,${replacementPhoto.base64Data}`, + ); + expect(screen.queryByRole('img', { + name: 'Profile photo unavailable for Synthetic Runner', + })).not.toBeInTheDocument(); + }); + + test.each([ + ['completed validation', 'x', 'validation'], + ['empty completion', 'synthetic-empty', 'empty'], + ['fixed failure', 'synthetic-failure', 'failure'], + ['result cards', 'synthetic-results', 'results'], + ])('clears query and rendered state after %s without making another request', async ( + _label, + query, + outcome, + ) => { + if (outcome === 'failure') { + (searchMemberDirectory as jest.Mock).mockRejectedValueOnce( + new Error('made-up-service-failure'), + ); + } else if (outcome === 'results') { + (searchMemberDirectory as jest.Mock).mockResolvedValueOnce({ + schemaVersion: 1, + results: [{ + entryRef: ENTRY_REF, + displayName: 'Synthetic Clear Runner', + photo, + }], + }); + } + renderDirectory(); + + submitSearch(query); + + if (outcome === 'validation') { + await screen.findByText(QUERY_REQUIREMENT_FOR_TEST); + } else if (outcome === 'empty') { + await screen.findByText( + 'No result cards are available for that name prefix. Try a longer prefix if you expected someone.', + ); + } else if (outcome === 'failure') { + await screen.findByText( + 'We could not complete that people-finder search. No results are shown. Try again later.', + ); + } else { + await screen.findByText('Synthetic Clear Runner'); + expect(screen.getByRole('img', { + name: 'Profile thumbnail for Synthetic Clear Runner', + })).toHaveAttribute('src', `data:image/webp;base64,${photo.base64Data}`); + expect(screen.getByText( + 'Search complete. Matching result cards are available below.', + )).toBeInTheDocument(); + } + + const clear = screen.getByRole('button', { + name: 'Clear search and result cards', + }); + const requestIdCalls = (createMemberDirectorySearchRequestId as jest.Mock).mock.calls.length; + const serviceCalls = (searchMemberDirectory as jest.Mock).mock.calls.length; + fireEvent.click(clear); + + expect(queryInput()).toHaveValue(''); + expect(queryInput()).toHaveFocus(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { level: 2, name: 'Opted-in people' })) + .not.toBeInTheDocument(); + expect(screen.queryByText('Synthetic Clear Runner')).not.toBeInTheDocument(); + expect(document.querySelector(`img[src="data:image/webp;base64,${photo.base64Data}"]`)) + .toBeNull(); + expect(screen.queryByText( + 'Search complete. Matching result cards are available below.', + )).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Search field and displayed result cards cleared.', + ); + expect(screen.getByRole('status')).not.toHaveTextContent(query); + expect(screen.queryByRole('button', { + name: 'Clear search and result cards', + })).not.toBeInTheDocument(); + expect(createMemberDirectorySearchRequestId).toHaveBeenCalledTimes(requestIdCalls); + expect(searchMemberDirectory).toHaveBeenCalledTimes(serviceCalls); + }); + + test('does not offer clearing while the finder is idle or pending', async () => { + const pending = deferred<{ schemaVersion: 1; results: [] }>(); + (searchMemberDirectory as jest.Mock).mockReturnValueOnce(pending.promise); + renderDirectory(); + + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + expect(screen.queryByRole('button', { + name: 'Clear search and result cards', + })).not.toBeInTheDocument(); + submitSearch('synthetic'); + expect(screen.getByRole('button', { name: 'Searching...' })).toBeDisabled(); + expect(screen.queryByRole('button', { + name: 'Clear search and result cards', + })).not.toBeInTheDocument(); + + await act(async () => { + pending.resolve({ schemaVersion: 1, results: [] }); + await pending.promise; + }); + expect(screen.getByRole('button', { + name: 'Clear search and result cards', + })).toBeInTheDocument(); + }); + + test('keeps native keyboard activation plus secondary contrast, focus, and 320px containment', async () => { + renderDirectory(); + submitSearch('x'); + + const clear = screen.getByRole('button', { + name: 'Clear search and result cards', + }); + expect(clear).toHaveClass( + 'member-directory-admin__button--secondary', + 'min-h-11', + 'max-w-full', + ); + expect(clear.tagName).toBe('BUTTON'); + expect(clear).toHaveAttribute('type', 'button'); + expect(clear.closest('.member-directory-admin__actions')).not.toBeNull(); + clear.focus(); + fireEvent.keyDown(clear, { key: 'Enter', code: 'Enter', charCode: 13 }); + fireEvent.click(clear); + fireEvent.keyUp(clear, { key: 'Enter', code: 'Enter', charCode: 13 }); + expect(queryInput()).toHaveFocus(); + expect(queryInput()).toHaveValue(''); + expect(screen.getByRole('status')).toHaveTextContent( + 'Search field and displayed result cards cleared.', + ); + + const css = readFileSync( + join(__dirname, '../../account/Account.css'), + 'utf8', + ); + expect(css).toMatch( + /\.member-directory-admin__button--secondary\s*\{[\s\S]*min-height:\s*2\.75rem;[\s\S]*color:\s*#1e3a8a;[\s\S]*background:\s*#fff;[\s\S]*border-color:\s*#1e40af;/, + ); + expect(css).toMatch( + /\.member-directory-admin__button--secondary:focus-visible\s*\{[\s\S]*outline:\s*3px solid #1e40af;[\s\S]*outline-offset:\s*3px;/, + ); + expect(css).toMatch( + /@media \(max-width:\s*320px\)\s*\{[\s\S]*\.member-directory-admin__actions\s*\{[\s\S]*flex-direction:\s*column;[\s\S]*width:\s*100%;[\s\S]*\.member-directory-admin__button--secondary\s*\{[\s\S]*width:\s*100%;[\s\S]*max-width:\s*100%;/, + ); + }); + }); }); diff --git a/src/pages/admin/members/AdminMemberDirectory.tsx b/src/pages/admin/members/AdminMemberDirectory.tsx index 9cabb4e..53a9fa2 100644 --- a/src/pages/admin/members/AdminMemberDirectory.tsx +++ b/src/pages/admin/members/AdminMemberDirectory.tsx @@ -38,23 +38,34 @@ type SearchState = | { phase: 'resolved'; results: readonly MemberDirectorySearchResult[] } | { phase: 'unavailable' }; -function PhotoFallback({ displayName }: { displayName: string }) { +function PhotoFallback({ + displayName, + unavailable = false, +}: { + displayName: string; + unavailable?: boolean; +}) { return (
- No photo + {unavailable ? 'Photo unavailable' : 'No photo'}
); } function DirectoryPhoto({ result }: { result: MemberDirectorySearchResult }) { const [failed, setFailed] = useState(false); - if (result.photo === null || failed) { + if (result.photo === null) { return ; } + if (failed) { + return ; + } return ( (null); const [state, setState] = useState({ phase: 'idle' }); + const [clearAnnouncement, setClearAnnouncement] = useState(null); + const queryInputRef = useRef(null); const mountedRef = useRef(false); const pendingRef = useRef(false); const operationRef = useRef(null); @@ -90,6 +103,7 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { setQueryInput(event.currentTarget.value); setValidationMessage(null); setState({ phase: 'idle' }); + setClearAnnouncement(null); } async function handleSearch(event: FormEvent) { @@ -101,6 +115,7 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { operationRef.current = null; setValidationMessage(QUERY_REQUIREMENT); setState({ phase: 'idle' }); + setClearAnnouncement(null); return; } @@ -110,6 +125,7 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { } catch { setValidationMessage(null); setState({ phase: 'unavailable' }); + setClearAnnouncement(null); return; } @@ -119,6 +135,7 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { setQueryInput(query); setValidationMessage(null); setState({ phase: 'pending' }); + setClearAnnouncement(null); try { const response = await searchMemberDirectory(app, { requestId, query }); @@ -134,7 +151,19 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { } } + function handleClear() { + operationRef.current = null; + setQueryInput(''); + setValidationMessage(null); + setState({ phase: 'idle' }); + setClearAnnouncement('Search field and displayed result cards cleared.'); + queryInputRef.current?.focus(); + } + const pending = state.phase === 'pending'; + const clearAvailable = validationMessage !== null + || state.phase === 'resolved' + || state.phase === 'unavailable'; const queryDescriptionIds = [ 'member-directory-query-help', validationMessage ? 'member-directory-query-validation' : null, @@ -167,6 +196,7 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { choose Search. - +
+ + {clearAvailable && ( + + )} +
+

+ {clearAnnouncement} +

+ {validationMessage && (