From 39a283eb143ce9ca3233bbbaf2b1c87a2537420b Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 13 Aug 2026 12:24:18 -0700 Subject: [PATCH] Harden member directory interfaces (#627) --- IMPLEMENTATION_PLAN.md | 2 + SECURITY.md | 1 + SYSTEM_DESIGN.md | 2 + docs/officers/EVENTS_SHOP_MEMBERS.md | 44 ++- src/pages/account/Account.css | 100 ++++++- src/pages/account/Account.test.tsx | 50 +++- src/pages/account/Account.tsx | 2 +- .../account/MemberDirectoryProfile.test.tsx | 250 ++++++++++++++++-- src/pages/account/MemberDirectoryProfile.tsx | 196 +++++++++++--- .../members/AdminMemberDirectory.test.tsx | 121 ++++++++- .../admin/members/AdminMemberDirectory.tsx | 49 ++-- 11 files changed, 733 insertions(+), 84 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 63e33cf0..1c766222 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -171,6 +171,8 @@ Exit gate: **Current optional-directory boundary:** Parent [#504](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/504) defines a private, opt-in officer people finder as name search with voluntary thumbnails—not facial recognition. MEMBERS-DIRECTORY-001A [#505](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/505) owns the signed-in person's server-only processed thumbnail and independent default-off preference. MEMBERS-DIRECTORY-001B [#506](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/506) adds source for the minimum server-only projection, current-state reconciler, bounded verified-admin name-prefix callable, query-free audit, and separate `/admin/member-directory` gallery. It returns at most 24 current opted-in display-name/optional-thumbnail cards and has no image query, facial recognition, cursor, total, export, or membership authority. MEMBERS-DIRECTORY-001D [#621](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/621) makes the shipped frontend boundary default unavailable: My Account and the guarded officer route show only visibly described, disabled layouts and perform zero directory reads, uploads, searches, saves, request-ID creation, or service calls. #623 published exactly that inert interface as deploy `6a7e072f8f346b0008510d29`; the temporary authority is re-paused. Protected layout proof remains synthetic. Signed-out public proof is limited to revision and guard readback plus the absence of a directory request. [#507](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/507) still owns #110 policy completion, scoped authorization, #133 protected authority, isolated staging, required backend/index deployment and readback, and a later separately reviewed source flip before connected website publication and live proof. Do not reuse the current browser-side full-account filter or describe the published disabled preview or #505/#506 source as an available directory. +**MEMBERS-DIRECTORY-001E current source boundary:** [#627](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/627) hardens only the preserved connected frontend. New opt-in uses the projection's exact bounded Unicode display-name eligibility; an existing opt-in remains removable after the name becomes ineligible. Profile placeholders and control-linked generic errors, explicit search validation state, a non-counting successful-search announcement, scoped explicit contrast, keyboard/touch geometry, 320-pixel containment, and stale file-read fencing improve accessibility and race behavior without adding a data path. The availability value remains `false`, so the default source branch remains inert; the live #623 preview remains unchanged. This issue changes no Functions, Rules, indexes, service contract, package, workflow, release control, provider, account, sign-in, or production data. #507 still owns connection, privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the reviewed availability flip, connected publication, and live proof. + ### 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 39565377..18592a86 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -100,6 +100,7 @@ These entries are implementation evidence, not a production risk-acceptance deci | Source-only containment for RISK-008, RISK-017, RISK-022, RISK-032, and RISK-042 | MEMBERS-DIRECTORY-001A [#505](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/505) adds four caller-only profile callables with native App Check enforcement and fail-closed private no-store responses. Strict requests, revisions, current-name opt-in, normalized 256-by-256 WebP output, metadata/animation/size rejection, separate default-off visibility, server-only preference/photo records, atomic append-oriented audits, and direct-browser denials are covered with generated non-face data. No original image, filename, provider photo URL, image-derived digest, face matching, or membership authority is stored or returned. The exact `sharp@0.35.0` dependency has no finding in the 2026-08-01 production audits; the audits still report 4 existing root findings (1 high, 2 moderate, 1 low) and 10 existing Functions findings (9 moderate, 1 low). | This slice is not live and by itself supplies no officer search; #506 adds that separate source boundary. Every upload or retry still writes stable, domain-separated, SHA-256 account-key abuse-control bookkeeping, consumes quota, and depends on an unverified TTL; that pseudonym is linkable and is not anonymization. Profile/photo/audit state alone is read-only on an exact retry. Complete #110 privacy/retention/backup decisions, #133 protected short-lived authority, #507 isolated staging and backend-first deployment/readback, approved public notice wording, synthetic opt-out/removal/cache proof, website publication, and `runmprc.com` verification before use. Do not inspect real profiles or enable biometric processing. | | Source-only containment for RISK-008, RISK-017, RISK-022, RISK-032, and RISK-042 | MEMBERS-DIRECTORY-001B [#506](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/506) adds one separate officer people-finder page, one native-App-Check verified-admin callable, and one current-state projection reconciler with idempotent background retry. The browser submits only an explicit UUID/name-prefix command and accepts at most 24 exact display-name/optional-thumbnail projections. Direct Firestore access is denied. The server rejects Unicode controls/formats and invalid canonical bounds, stores only prefix digests under a proven 272-entry ceiling, permits 30 searches per verified-admin account/hour, queries at most 48 candidates, re-reads each current member/preference/photo record before returning it, and atomically records one minimal query-free audit. An opted-out, missing-name, malformed, stale, or now-nonmatching candidate is hidden. The existing full-account admin filter is visibly separate. | The projection's internal UID key, prefix digests, entry reference, rate key, and audit identity remain stable/linkable server-side pseudonyms, not anonymization. A response still contains personal data for its approved purpose. Exact request-ID reuse gets a fixed denial instead of retained-result replay and creates no second audit; the rate limiter may still consume an attempt. The current verified `admin` role is only a documented compatibility boundary, not a scoped people-finder capability. Source/tests/preview do not configure TTL, privacy notice, retention/backups, repair/backfill ownership, staging, Firebase Rules/Functions/indexes or trigger policy, Netlify, or production behavior. Complete #110, AUTH-003, #133, and #507 before use; never test against real profiles or add image queries, facial recognition, embeddings, similarity, analytics, or raw-query logs. | | 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. | | 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 e2cc73e9..97a7334b 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -683,6 +683,8 @@ The preserved connected branch sends nothing while the person types. Search happ **MEMBERS-DIRECTORY-001D frontend boundary:** [#621](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/621) puts both directory interfaces behind one source-controlled availability boundary that defaults unavailable. In that default state, My Account renders the future thumbnail placeholder, file control, and independent finder choice as natively disabled controls under a visible preview notice. It does not mount the connected profile branch, read a file or saved setting, create a request ID, or initialize or call a member-directory service. The separate `/admin/member-directory` route remains behind `AdminGuard`, but its name field and Search button are disabled; it accepts no name, creates no request ID, initializes no search service, and renders no sample, fake, or result card. This is an inert layout preview, not client-side authorization and not a connected feature. +**MEMBERS-DIRECTORY-001E frontend hardening — SOURCE ONLY:** [#627](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/627) hardens only the preserved connected Account and People-finder branches while the source-controlled availability value remains `false`. The Account branch derives new opt-in eligibility from the same bounded Unicode display-name contract as the server projection: the raw and trimmed name must fit the 200-UTF-16-unit bounds, contain no control or format character or unpaired surrogate, and yield 2–200 units of NFKC-normalized lowercase letter/number tokens. A person whose existing choice is on can still turn it off after the current name becomes missing or ineligible. No-photo placeholders have image semantics; generic photo and setting errors describe the affected control; the name-search field exposes its validation state and descriptions; and a successful non-empty search announces completion without disclosing a result total. Native controls keep keyboard operation and at least 44-pixel interaction height, the search action stacks at narrow widths, and scoped explicit foreground/background colors keep the form, button, messages, fallback, and cards readable even though the repository's custom Tailwind palette omits those utility colors. A deferred file read is inert after its Account application or user context changes. Current-name updates immediately recompute the connected UI eligibility. These are accessibility, validation, stale-result, and 320-pixel layout changes only: they add no service call, authority, data field, backend, package, workflow, release, provider, or production-data behavior. The live #623 deployment remains unchanged; #507 still owns privacy approval, scoped authorization, isolated staging, backend-first deployment/readback, the availability flip, connected publication, and live proof. + 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 3f70a785..a094598d 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2433,12 +2433,14 @@ flowchart TD Search --> None Release["#623 exact inert artifact published"] --> Public["Completed signed-out revision and guard readback only"] Public -. "does not prove protected layout" .-> Synthetic + Hardening["#627 connected-interface hardening — SOURCE ONLY"] --> Preserved["Accessible Account and People finder branches preserved behind false availability"] + 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. 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: 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, so that hardened structure is not 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. Officer review steps for the #621 frontend preview: @@ -2533,6 +2535,46 @@ Officer review steps for the preserved #506 connected source only: 39. Confirm loading, no-result, fixed-error, and missing-photo states remain readable. 40. Stop. Do not enable connected production Account or Admin controls, search a real name, inspect a real profile, or edit Firebase. +Officer review steps for MEMBERS-DIRECTORY-001E [#627] connected source only: + +The section purpose, approvers, stop conditions, undo path, and escalation roles remain unchanged. This review checks source and generated test evidence only. + +1. Keep the complete profile-photo and People-finder feature marked **NOT AVAILABLE YET**. +2. Ask the platform owner for the exact #627 reviewed pull request, merge commit, and generated-only frontend test record. +3. Confirm the source-controlled availability value remains byte-for-byte `false`. +4. Confirm production deploy `6a7e072f8f346b0008510d29` remains the unchanged inert #623 preview. +5. Confirm the tests use only made-up names and generated non-face images. +6. Confirm My Account supplies the current full display name to the preserved connected profile controls. +7. Confirm new opt-in uses the same bounded Unicode display-name eligibility as the private projection. +8. Confirm an ineligible current name cannot turn on a new finder choice. +9. Confirm an existing finder choice can still be turned off after the current name becomes ineligible. +10. Confirm a current-name update immediately changes the connected controls' eligibility message and state. +11. Confirm every no-photo placeholder is announced as an image with a clear no-photo label. +12. Confirm a photo-selection or photo-change error describes the file control. +13. Confirm a photo-removal error describes the remove button. +14. Confirm a finder-setting error describes the finder checkbox. +15. Confirm those fixed errors expose no provider, account, endpoint, or raw failure detail. +16. Confirm an invalid finder query marks the name field invalid. +17. Confirm the invalid-query instruction describes the name field. +18. Confirm a fixed search failure describes the name field without marking the name invalid. +19. Confirm a successful non-empty search announces `Search complete. Matching result cards are available below.` +20. Confirm the completion announcement exposes no result count. +21. Confirm the connected result view exposes no total, cursor, or pagination. +22. Confirm the native checkbox, name field, and buttons remain keyboard-operable. +23. Confirm the interactive controls retain at least a 44-pixel height. +24. Confirm the form, input, action, messages, fallback, and result cards use explicit readable foreground and background colors. +25. Confirm the search action stacks within the 320-pixel synthetic view. +26. Confirm a deferred file read cannot submit after the application or made-up account changes. +27. Confirm a deferred file read cannot render an older error or result after that context changes. +28. Confirm the default Account and administrator-guarded branches still accept no file or name. +29. Confirm the default branches still create no request number or directory service context. +30. Confirm #627 changes no Function, Rule, index, service contract, package, workflow, release control, provider setting, account, sign-in state, or production data. +31. Record source change, tests, merge, preview, website publication, exact `runmprc.com` revision, Firebase, provider configuration, account/sign-in change, production data, and connected behavior as separate results. + +**#627 expected result:** the reviewed source applies the projection's exact name-eligibility boundary to new opt-in, preserves turn-off after a name becomes ineligible, improves control and placeholder semantics, announces a non-empty search without a count, uses scoped readable colors, contains native controls at 320 pixels, and makes an older file read inert after its Account context changes. The availability value remains `false`. The default branch remains inert, and the live #623 preview remains unchanged. Neither makes a directory request. The backend and connected behavior remain **NOT AVAILABLE YET**. A merge is not website publication. + +**#627 success proof:** record the exact issue, reviewed pull request and merge commit, green focused and full frontend checks, type-checking, diagnostic production build, unchanged lint baseline, independent privacy/security, frontend/accessibility, and backup-officer GO reviews, and exact-main CI. Record the unchanged `false` availability value and the unchanged #623 deploy separately. Record website publication, `runmprc.com` revision change, Firebase deployment, Rules or index change, provider configuration, account/sign-in change, production-data action, and connected behavior as **not performed**. Final connection and live proof remain #507 work. + **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 ea8d0673..a488022e 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -170,12 +170,14 @@ } .member-directory-profile { + min-width: 0; margin-top: 1.5rem; padding: 1rem; color: #111827; background: #f9fafb; border: 1px solid #d1d5db; border-radius: 0.5rem; + overflow-wrap: anywhere; } .member-directory-profile h2 { @@ -209,6 +211,62 @@ margin-top: 1rem; } +.member-directory-admin__search, +.member-directory-admin__card { + color: #111827; + background: #f9fafb; + border-color: #d1d5db; +} + +.member-directory-admin__label, +.member-directory-admin__card h3 { + color: #111827; +} + +.member-directory-admin__help { + color: #374151; +} + +.member-directory-admin__input { + color: #111827; + background: #fff; + border: 1px solid #6b7280; +} + +.member-directory-admin__button { + color: #fff; + background: #1e40af; + border-color: #1e40af; +} + +.member-directory-admin__button:hover:not(:disabled) { + color: #fff; + background: #1e3a8a; + border-color: #1e3a8a; +} + +.member-directory-admin__message { + border: 1px solid; +} + +.member-directory-admin__message--warning { + color: #422006; + background: #fffbeb; + border-color: #d97706; +} + +.member-directory-admin__message--error { + color: #7f1d1d; + background: #fef2f2; + border-color: #b91c1c; +} + +.member-directory-admin__photo-fallback { + color: #374151; + background: #f3f4f6; + border-color: #9ca3af; +} + .member-directory-profile__preview { display: flex; flex-direction: column; @@ -276,6 +334,13 @@ width: 100%; } +.member-directory-profile__photo-actions input[type='file'] { + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + font: inherit; +} + .member-directory-profile__visibility { padding-top: 1rem; border-top: 1px solid #d1d5db; @@ -283,11 +348,23 @@ overflow-wrap: anywhere; } +.member-directory-profile__visibility label { + display: flex; + box-sizing: border-box; + width: 100%; + min-height: 2.75rem; + padding: 0.625rem 0; + align-items: flex-start; + gap: 0.625rem; + line-height: 1.5; + cursor: pointer; +} + .member-directory-profile__visibility input { - width: 1.1rem; - height: 1.1rem; - margin-right: 0.5rem; - vertical-align: -0.15rem; + width: 1.25rem; + height: 1.25rem; + margin: 0.125rem 0 0; + flex: 0 0 1.25rem; } .member-directory-profile button { @@ -328,6 +405,21 @@ border-radius: 0.375rem; } +@media (max-width: 359px) { + .member-directory-profile { + padding: 0.75rem; + } + + .member-directory-profile__photo { + align-items: flex-start; + } + + .member-directory-profile__photo img, + .member-directory-profile__placeholder { + max-width: 100%; + } +} + @media (min-width: 640px) { .account-verification-notice { align-items: flex-start; diff --git a/src/pages/account/Account.test.tsx b/src/pages/account/Account.test.tsx index bf29ad6d..a5c8bd4f 100644 --- a/src/pages/account/Account.test.tsx +++ b/src/pages/account/Account.test.tsx @@ -250,7 +250,18 @@ describe('Account profile recovery', () => { expect(getMyProfile).toHaveBeenCalledWith(firestore, USER.uid); }); - test('binds the directory controls to the current app, account, and display-name state', async () => { + test('passes the exact current saved display name to the directory controls', async () => { + renderAccount(); + + expect(await screen.findByTestId('member-directory-profile')).toBeInTheDocument(); + expect(mockMemberDirectoryProfile).toHaveBeenLastCalledWith({ + app, + uid: USER.uid, + displayName: PROFILE.fullName, + }); + }); + + test('passes a missing current saved display name without deriving eligibility', async () => { (getMyProfile as jest.Mock).mockResolvedValue({ ...PROFILE, fullName: null }); renderAccount(); @@ -259,8 +270,43 @@ describe('Account profile recovery', () => { expect(mockMemberDirectoryProfile).toHaveBeenLastCalledWith({ app, uid: USER.uid, - hasDisplayName: false, + displayName: null, + }); + }); + + test('updates the directory display-name prop only after the saved profile is reread', async () => { + const updatedProfile = { + ...PROFILE, + fullName: 'Updated Synthetic Member', + }; + (getMyProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE) + .mockResolvedValueOnce(updatedProfile); + renderAccount(); + + expect(await screen.findByText(PROFILE.fullName)).toBeInTheDocument(); + expect(mockMemberDirectoryProfile).toHaveBeenLastCalledWith({ + app, + uid: USER.uid, + displayName: PROFILE.fullName, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.change(screen.getByLabelText('Full name'), { + target: { value: updatedProfile.fullName }, }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mockMemberDirectoryProfile).toHaveBeenLastCalledWith({ + app, + uid: USER.uid, + displayName: updatedProfile.fullName, + })); + expect(updateMyProfile).toHaveBeenCalledWith( + firestore, + USER.uid, + { fullName: updatedProfile.fullName }, + ); }); test('shows the shared page hero while authentication is loading', () => { diff --git a/src/pages/account/Account.tsx b/src/pages/account/Account.tsx index ea5bd325..7500ff05 100644 --- a/src/pages/account/Account.tsx +++ b/src/pages/account/Account.tsx @@ -796,7 +796,7 @@ export function AccountContent({ )} diff --git a/src/pages/account/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index e0d55834..6fc5e432 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -14,7 +14,9 @@ import { setMyMemberDirectoryPhoto, setMyMemberDirectoryVisibility, } from '../../services/account/memberDirectoryService'; -import MemberDirectoryProfile from './MemberDirectoryProfile'; +import MemberDirectoryProfile, { + isMemberDirectoryDisplayNameEligible, +} from './MemberDirectoryProfile'; jest.mock('../../services/account/memberDirectoryService', () => { const actual = jest.requireActual('../../services/account/memberDirectoryService'); @@ -67,24 +69,55 @@ function deferred() { function renderProfile({ firebaseApp = app, uid = 'synthetic-user', - hasDisplayName = true, + displayName = 'Synthetic Member', backendAvailable = true, }: { firebaseApp?: typeof app; uid?: string; - hasDisplayName?: boolean; + displayName?: string | null; backendAvailable?: boolean; } = {}) { return render( , ); } +describe('MEMBERS-DIRECTORY-001E display-name eligibility', () => { + test.each([ + ['ordinary synthetic name', 'Synthetic Member'], + ['NFKC-normalized synthetic name', '\uff33\uff39\uff2e\uff34\uff28\uff25\uff34\uff29\uff23'], + ['combining-mark synthetic name', 'A\u0301B'], + ['one supplementary Unicode letter occupying two UTF-16 units', '\ud801\udc00'], + ['exact raw and canonical bound', 'x'.repeat(200)], + ])('accepts an eligible %s', (_label, displayName) => { + expect(isMemberDirectoryDisplayNameEligible(displayName)).toBe(true); + }); + + test.each([ + ['missing name', null], + ['empty name', ''], + ['blank name', ' '], + ['one-unit Latin name text', 'A'], + ['one-unit non-Latin name text', '\u4e2d'], + ['one-unit lowercase expansion candidate', '\u00df'], + ['punctuation-only name', '--'], + ['punctuation around one-unit name text', '-A-'], + ['control character', 'Synthetic\u0085Member'], + ['format character', 'Synthetic\u200dMember'], + ['unpaired high surrogate', 'Synthetic\ud800Member'], + ['unpaired low surrogate', 'Synthetic\udc00Member'], + ['raw text over the UTF-16 bound', 'x'.repeat(201)], + ['NFKC expansion over the canonical bound', '\ufdfa'.repeat(12)], + ])('rejects an ineligible %s', (_label, displayName) => { + expect(isMemberDirectoryDisplayNameEligible(displayName)).toBe(false); + }); +}); + describe('My Account member directory profile', () => { beforeEach(() => { jest.clearAllMocks(); @@ -121,7 +154,7 @@ describe('My Account member directory profile', () => { , ); @@ -135,7 +168,8 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('status')).toHaveTextContent( 'No photo or finder setting is read, uploaded, searched, or saved', ); - expect(screen.getByLabelText('Profile photo preview')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Profile photo preview' })) + .toBeInTheDocument(); const file = screen.getByLabelText('Add profile photo (not available yet)'); const checkbox = screen.getByRole('checkbox', { name: 'Let authorized officers find me by name (not available yet)', @@ -176,7 +210,7 @@ describe('My Account member directory profile', () => { , ); @@ -187,7 +221,7 @@ describe('My Account member directory profile', () => { expect(checkbox.getAttribute('aria-describedby')).toContain( 'member-directory-name-required-preview', ); - expect(screen.getByText(/full name in the Profile section will also be required/i)) + expect(screen.getByText(/eligible name in the Profile section will also be required/i)) .toBeInTheDocument(); expect(getMyMemberDirectoryProfile).not.toHaveBeenCalled(); }); @@ -199,7 +233,8 @@ describe('My Account member directory profile', () => { level: 2, name: 'Profile photo and officer finder', })).toBeInTheDocument(); - expect(await screen.findByLabelText('No profile photo')).toBeInTheDocument(); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: 'Let verified website administrators find me by name', })).not.toBeChecked(); @@ -308,7 +343,13 @@ describe('My Account member directory profile', () => { fireEvent.change(input, { target: { files: [makeFile()] } }); - expect(await screen.findByRole('alert')).toHaveTextContent(message); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent(message); + expect(alert).toHaveAttribute('id', 'member-directory-action-error'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-action-error', + ); expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); }); @@ -389,16 +430,61 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('status')).not.toHaveTextContent('Officer finder is off.'); }); - test('requires a saved display name before opt-in while leaving photo upload available', async () => { - renderProfile({ hasDisplayName: false }); + test.each([ + ['missing', null], + ['one-unit', 'A'], + ['punctuation-only', '---'], + ['format-containing', 'Synthetic\u200dMember'], + ['canonical expansion beyond the bound', '\ufdfa'.repeat(12)], + ])( + 'requires an eligible display name before opt-in for a %s name', + async (_label, displayName) => { + renderProfile({ displayName }); + + const checkbox = await screen.findByRole('checkbox', { + name: 'Let verified website administrators find me by name', + }); + expect(checkbox).toBeDisabled(); + expect(screen.getByText(/current Profile name is not eligible/i)) + .toBeInTheDocument(); + expect(screen.getByLabelText('Add profile photo')).toBeEnabled(); + expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); + }, + ); + test('recomputes new opt-in eligibility when the current saved name changes', async () => { + const view = renderProfile({ displayName: 'Synthetic Member' }); const checkbox = await screen.findByRole('checkbox', { name: 'Let verified website administrators find me by name', }); - expect(checkbox).toBeDisabled(); - expect(screen.getByText(/add your full name in the Profile section/i)) + expect(checkbox).toBeEnabled(); + expect(screen.queryByText(/current Profile name is not eligible/i)) + .not.toBeInTheDocument(); + + view.rerender( + , + ); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect(screen.getByText(/current Profile name is not eligible/i)) .toBeInTheDocument(); - expect(screen.getByLabelText('Add profile photo')).toBeEnabled(); + + view.rerender( + , + ); + expect(screen.getByRole('checkbox')).toBeEnabled(); + expect(screen.queryByText(/current Profile name is not eligible/i)) + .not.toBeInTheDocument(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); }); test('keeps controls available when a secure request ID cannot be created', async () => { @@ -412,15 +498,27 @@ describe('My Account member directory profile', () => { expect(await screen.findByRole('alert')).toHaveTextContent( /could not safely start that change/i, ); - expect(screen.getByRole('checkbox')).toBeEnabled(); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toBeEnabled(); + expect(checkbox).not.toHaveAttribute('aria-invalid'); + expect(checkbox.getAttribute('aria-describedby')).toContain( + 'member-directory-action-error', + ); expect(screen.getByLabelText('Add profile photo')).toBeEnabled(); expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); expect(document.body).not.toHaveTextContent('synthetic crypto unavailable'); }); - test('keeps an existing opt-in removable but hidden when the display name is cleared', async () => { - (getMyMemberDirectoryProfile as jest.Mock).mockResolvedValue(PROFILE_WITH_PHOTO); - renderProfile({ hasDisplayName: false }); + test('keeps an existing opt-in removable but hidden when the display name is ineligible', async () => { + const hiddenProfile = { + ...PROFILE_WITH_PHOTO, + revision: 5, + searchableByOfficers: false, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(hiddenProfile); + renderProfile({ displayName: 'Synthetic\u200dMember' }); const checkbox = await screen.findByRole('checkbox', { name: 'Let verified website administrators find me by name', @@ -430,9 +528,22 @@ describe('My Account member directory profile', () => { expect(checkbox.getAttribute('aria-describedby')).toContain( 'member-directory-name-required', ); - expect(screen.getByText(/officers cannot find you until you add a full name/i)) + expect(screen.getByText(/officers cannot find you while your current Profile name is ineligible/i)) .toBeInTheDocument(); expect(screen.getByText(/you can still turn the setting off/i)).toBeInTheDocument(); + + fireEvent.click(checkbox); + + await waitFor(() => expect(setMyMemberDirectoryVisibility).toHaveBeenCalledWith( + app, + { + requestId: REQUEST_ID, + expectedRevision: 4, + searchableByOfficers: false, + }, + )); + expect(await screen.findByRole('checkbox')).not.toBeChecked(); + expect(screen.getByRole('status')).toHaveTextContent('Officer finder is off.'); }); test('removes a photo without changing an enabled finder setting', async () => { @@ -455,7 +566,8 @@ describe('My Account member directory profile', () => { expectedRevision: 4, })); expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); - expect(await screen.findByLabelText('No profile photo')).toBeInTheDocument(); + expect(await screen.findByRole('img', { name: 'No profile photo' })) + .toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: 'Let verified website administrators find me by name', })).toBeChecked(); @@ -580,7 +692,7 @@ describe('My Account member directory profile', () => { , ); @@ -608,7 +720,7 @@ describe('My Account member directory profile', () => { , ); @@ -621,6 +733,91 @@ describe('My Account member directory profile', () => { expect(screen.queryByText('Officer finder is on.')).not.toBeInTheDocument(); }); + test.each([ + ['application', otherApp, 'synthetic-user'], + ['account', app, 'other-synthetic-user'], + ])( + 'makes a deferred file read inert after the %s changes', + async (_label, nextApp, nextUid) => { + const OriginalFileReader = globalThis.FileReader; + let completeRead: (() => void) | null = null; + class DeferredFileReader { + result: string | null = null; + + onload: (() => void) | null = null; + + onerror: (() => void) | null = null; + + onabort: (() => void) | null = null; + + readAsDataURL() { + completeRead = () => { + this.result = `data:image/png;base64,${btoa('deferred synthetic pixels')}`; + this.onload?.(); + }; + } + } + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + writable: true, + value: DeferredFileReader, + }); + + try { + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO); + const view = renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + + fireEvent.change(input, { + target: { + files: [new File( + ['deferred synthetic pixels'], + 'deferred.png', + { type: 'image/png' }, + )], + }, + }); + expect(await screen.findByRole('status')).toHaveTextContent( + 'Saving profile photo...', + ); + + view.rerender( + , + ); + expect(await screen.findByRole('img', { + name: 'Your current profile thumbnail', + })).toBeInTheDocument(); + + await act(async () => { + if (completeRead === null) throw new Error('synthetic deferred read missing'); + completeRead(); + await Promise.resolve(); + }); + + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(screen.getByRole('img', { name: 'Your current profile thumbnail' })) + .toHaveAttribute('src', `data:image/webp;base64,${PHOTO.base64Data}`); + expect(screen.getByRole('checkbox')).toBeChecked(); + expect(screen.queryByText('Profile photo saved.')).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + } finally { + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + writable: true, + value: OriginalFileReader, + }); + } + }, + ); + test('ignores an initial load completion after unmount', async () => { const load = deferred(); (getMyMemberDirectoryProfile as jest.Mock).mockReturnValueOnce(load.promise); @@ -666,5 +863,12 @@ describe('My Account member directory profile', () => { expect(css).toMatch( /\.member-directory-profile__photo-actions\s*\{[\s\S]*flex:\s*1 1 15rem;[\s\S]*width:\s*100%;/, ); + expect(css).toMatch( + /\.member-directory-profile__photo-actions input\[type='file'\]\s*\{[\s\S]*width:\s*100%;[\s\S]*min-height:\s*2\.75rem;/, + ); + expect(css).toMatch( + /\.member-directory-profile__visibility label\s*\{[\s\S]*min-height:\s*2\.75rem;/, + ); + expect(css).toMatch(/@media \(max-width:\s*359px\)/); }); }); diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index 46fa223c..90ff040e 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -14,15 +14,60 @@ import { } from '../../services/account/memberDirectoryService'; const MAX_UPLOAD_BYTES = 2 * 1024 * 1024; +const MAX_DISPLAY_NAME_CODE_UNITS = 200; +const MIN_CANONICAL_DISPLAY_NAME_CODE_UNITS = 2; +const CONTROL_OR_FORMAT_PATTERN = /[\p{Cc}\p{Cf}]/u; +const DIRECTORY_TOKEN_PATTERN = /[\p{L}\p{N}][\p{L}\p{M}\p{N}]*/gu; const LOAD_FAILURE_MESSAGE = 'We could not load your profile photo and officer finder settings. No setting was changed. Reload settings to try again.'; const UNKNOWN_CHANGE_MESSAGE = 'We could not confirm that change. Do not make another change yet. Reload settings to check what is currently saved.'; const REJECTED_CHANGE_MESSAGE = 'That change was rejected before it was saved. Review the requirements and try again.'; const REQUEST_UNAVAILABLE_MESSAGE = 'This browser could not safely start that change. No setting was changed. Reload the page and try again.'; +function hasForbiddenDisplayNameUnicode(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return true; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return true; + } + } + return CONTROL_OR_FORMAT_PATTERN.test(value); +} + +export function isMemberDirectoryDisplayNameEligible(value: unknown): boolean { + if ( + typeof value !== 'string' + || value.length === 0 + || value.length > MAX_DISPLAY_NAME_CODE_UNITS + || hasForbiddenDisplayNameUnicode(value) + ) return false; + + try { + const displayName = value.trim(); + if ( + displayName.length === 0 + || displayName.length > MAX_DISPLAY_NAME_CODE_UNITS + ) return false; + const tokens = displayName + .normalize('NFKC') + .toLowerCase() + .match(DIRECTORY_TOKEN_PATTERN); + if (tokens === null) return false; + const canonical = tokens.join(' '); + return canonical.length >= MIN_CANONICAL_DISPLAY_NAME_CODE_UNITS + && canonical.length <= MAX_DISPLAY_NAME_CODE_UNITS; + } catch { + return false; + } +} + function MemberDirectoryProfilePreview({ - hasDisplayName, + displayNameEligible, }: { - hasDisplayName: boolean; + displayNameEligible: boolean; }) { return (
Photo preview @@ -94,15 +140,19 @@ function MemberDirectoryProfilePreview({ aria-describedby={[ 'member-directory-preview-status', 'member-directory-privacy-description', - !hasDisplayName ? 'member-directory-name-required-preview' : null, + !displayNameEligible + ? 'member-directory-name-required-preview' + : null, ].filter(Boolean).join(' ')} /> Let authorized officers find me by name (not available yet) - {!hasDisplayName && ( + {!displayNameEligible && (

- A full name in the Profile section will also be required when the - finder is connected. + An eligible name in the Profile section will also be required when + the finder is connected. It must remain within the 200-character + limit after normalization, produce searchable name text at least two + characters long, and contain no control or format characters.

)}
@@ -144,6 +194,13 @@ type MutationStart = { lifetime: symbol; operation: symbol; profile: MemberDirectoryProfileData; + action: 'visibility' | 'upload' | 'remove'; +}; + +type ActionError = { + control: 'visibility' | 'file' | 'remove'; + invalid: boolean; + message: string; }; type MutationConfirmation = ( @@ -205,15 +262,15 @@ function readFileAsBase64(file: File): Promise { function MemberDirectoryProfileAttempt({ app, uid, - hasDisplayName, + displayNameEligible, }: { app: FirebaseApp; uid: string; - hasDisplayName: boolean; + displayNameEligible: boolean; }) { const [state, setState] = useState({ phase: 'loading' }); const [reloadAttempt, setReloadAttempt] = useState(0); - const [fileError, setFileError] = useState(null); + const [actionError, setActionError] = useState(null); const lifetimeRef = useRef(null); const loadRef = useRef(null); const mutationRef = useRef(null); @@ -234,7 +291,7 @@ function MemberDirectoryProfileAttempt({ let active = true; loadRef.current = load; mutationRef.current = null; - setFileError(null); + setActionError(null); setState({ phase: 'loading' }); async function loadProfile() { @@ -278,9 +335,10 @@ function MemberDirectoryProfileAttempt({ lifetime: lifetimeRef.current, operation, profile: state.profile, + action, }; mutationRef.current = operation; - setFileError(null); + setActionError(null); setState({ phase: 'pending', profile: state.profile, action }); return start; } @@ -310,17 +368,21 @@ function MemberDirectoryProfileAttempt({ if (!mutationIsCurrent(start)) return; mutationRef.current = null; setState({ phase: 'ready', profile, confirmation: null }); - setFileError(REJECTED_CHANGE_MESSAGE); + setActionError({ + control: start.action === 'upload' ? 'file' : start.action, + invalid: false, + message: REJECTED_CHANGE_MESSAGE, + }); } catch { if (!mutationIsCurrent(start)) return; mutationRef.current = null; - setFileError(null); + setActionError(null); setState({ phase: 'unavailable' }); } return; } mutationRef.current = null; - setFileError(null); + setActionError(null); setState({ phase: 'unknown' }); } } @@ -331,7 +393,11 @@ function MemberDirectoryProfileAttempt({ try { requestId = createMemberDirectoryRequestId(); } catch { - setFileError(REQUEST_UNAVAILABLE_MESSAGE); + setActionError({ + control: 'visibility', + invalid: false, + message: REQUEST_UNAVAILABLE_MESSAGE, + }); return; } const start = startMutation('visibility'); @@ -352,7 +418,11 @@ function MemberDirectoryProfileAttempt({ try { requestId = createMemberDirectoryRequestId(); } catch { - setFileError(REQUEST_UNAVAILABLE_MESSAGE); + setActionError({ + control: 'remove', + invalid: false, + message: REQUEST_UNAVAILABLE_MESSAGE, + }); return; } const start = startMutation('remove'); @@ -371,15 +441,23 @@ function MemberDirectoryProfileAttempt({ const input = event.currentTarget; const file = input.files?.[0] || null; input.value = ''; - setFileError(null); + setActionError(null); if (file === null) return; const { type: contentType } = file; if (!isAcceptedUploadType(contentType)) { - setFileError('Choose a JPG, PNG, or WebP image.'); + setActionError({ + control: 'file', + invalid: true, + message: 'Choose a JPG, PNG, or WebP image.', + }); return; } if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) { - setFileError('Choose a non-empty image that is 2 MiB or smaller.'); + setActionError({ + control: 'file', + invalid: true, + message: 'Choose a non-empty image that is 2 MiB or smaller.', + }); return; } @@ -387,7 +465,11 @@ function MemberDirectoryProfileAttempt({ try { requestId = createMemberDirectoryRequestId(); } catch { - setFileError(REQUEST_UNAVAILABLE_MESSAGE); + setActionError({ + control: 'file', + invalid: false, + message: REQUEST_UNAVAILABLE_MESSAGE, + }); return; } @@ -400,7 +482,11 @@ function MemberDirectoryProfileAttempt({ if (!mutationIsCurrent(start)) return; mutationRef.current = null; setState({ phase: 'ready', profile: start.profile, confirmation: null }); - setFileError('We could not read that image. Choose the file again.'); + setActionError({ + control: 'file', + invalid: true, + message: 'We could not read that image. Choose the file again.', + }); return; } if (!mutationIsCurrent(start)) return; @@ -461,8 +547,9 @@ function MemberDirectoryProfileAttempt({ {(state.phase === 'ready' || state.phase === 'pending') && (() => { const { profile } = state; const pending = state.phase === 'pending'; - const finderNeedsName = !hasDisplayName && !profile.searchableByOfficers; - const finderHiddenWithoutName = !hasDisplayName && profile.searchableByOfficers; + const finderNeedsName = !displayNameEligible && !profile.searchableByOfficers; + const finderHiddenWithoutName = !displayNameEligible + && profile.searchableByOfficers; let pendingMessage = ''; if (state.phase === 'pending') { if (state.action === 'upload') pendingMessage = 'Saving profile photo...'; @@ -480,7 +567,11 @@ function MemberDirectoryProfileAttempt({ alt="Your current profile thumbnail" /> ) : ( -
+
No photo
)} @@ -494,7 +585,17 @@ function MemberDirectoryProfileAttempt({ accept="image/jpeg,image/png,image/webp" onChange={handlePhotoSelection} disabled={pending} - aria-describedby="member-directory-file-help member-directory-privacy-description" + aria-invalid={actionError?.control === 'file' + && actionError.invalid + ? true + : undefined} + aria-describedby={[ + 'member-directory-file-help', + 'member-directory-privacy-description', + actionError?.control === 'file' + ? 'member-directory-action-error' + : null, + ].filter(Boolean).join(' ')} /> JPG, PNG, or WebP. Maximum 2 MiB. The saved thumbnail is processed @@ -505,6 +606,9 @@ function MemberDirectoryProfileAttempt({ type="button" onClick={handleRemovePhoto} disabled={pending} + aria-describedby={actionError?.control === 'remove' + ? 'member-directory-action-error' + : undefined} > Remove profile photo @@ -520,29 +624,46 @@ function MemberDirectoryProfileAttempt({ checked={profile.searchableByOfficers} onChange={handleVisibilityChange} disabled={pending || finderNeedsName} + aria-invalid={actionError?.control === 'visibility' + && actionError.invalid + ? true + : undefined} aria-describedby={[ 'member-directory-privacy-description', - !hasDisplayName ? 'member-directory-name-required' : null, + !displayNameEligible ? 'member-directory-name-required' : null, + actionError?.control === 'visibility' + ? 'member-directory-action-error' + : null, ].filter(Boolean).join(' ')} /> Let verified website administrators find me by name {finderNeedsName && (

- Add your full name in the Profile section before turning this on. + Your current Profile name is not eligible for officer-finder + search. Before turning this on, save a name that remains within + the 200-character limit after normalization, produces searchable + name text at least two characters long, and contains no control + or format characters.

)} {finderHiddenWithoutName && (

- This setting is on, but officers cannot find you until you add a - full name in the Profile section. You can still turn the setting off. + This setting is on, but officers cannot find you while your current + Profile name is ineligible. You can still turn the setting off. + Save an eligible name in the Profile section to become findable again.

)}
- {fileError && ( -

- {fileError} + {actionError && ( +

)} {pendingMessage && ( @@ -565,16 +686,19 @@ function MemberDirectoryProfileAttempt({ export default function MemberDirectoryProfile({ app, uid, - hasDisplayName, + displayName, backendAvailable = MEMBER_DIRECTORY_BACKEND_AVAILABLE, }: { app: FirebaseApp; uid: string; - hasDisplayName: boolean; + displayName: string | null; backendAvailable?: boolean; }) { + const displayNameEligible = isMemberDirectoryDisplayNameEligible(displayName); if (!backendAvailable) { - return ; + return ( + + ); } return ( @@ -582,7 +706,7 @@ export default function MemberDirectoryProfile({ key={`${appIdentity(app)}:${uid}`} app={app} uid={uid} - hasDisplayName={hasDisplayName} + displayNameEligible={displayNameEligible} /> ); } diff --git a/src/pages/admin/members/AdminMemberDirectory.test.tsx b/src/pages/admin/members/AdminMemberDirectory.test.tsx index c94937a7..5b085230 100644 --- a/src/pages/admin/members/AdminMemberDirectory.test.tsx +++ b/src/pages/admin/members/AdminMemberDirectory.test.tsx @@ -1,8 +1,10 @@ /* eslint-env jest */ +import { readFileSync } from 'fs'; +import { join } from 'path'; import React from 'react'; import { - act, fireEvent, render, screen, waitFor, + act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { useServiceLocator } from '../../../services/ServiceLocatorContext'; @@ -198,6 +200,50 @@ describe('Admin People finder', () => { expect(searchMemberDirectory).not.toHaveBeenCalled(); }); + test('ties validation and fixed search failures to the search input', async () => { + renderDirectory(); + + const input = queryInput(); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input).toHaveAttribute( + 'aria-describedby', + 'member-directory-query-help', + ); + + submitSearch('x'); + + const validation = screen.getByRole('alert'); + expect(validation).toHaveAttribute('id', 'member-directory-query-validation'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-query-help', + ); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-query-validation', + ); + + fireEvent.change(input, { target: { value: 'synthetic' } }); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input.getAttribute('aria-describedby')).not.toContain( + 'member-directory-query-validation', + ); + + (searchMemberDirectory as jest.Mock).mockRejectedValue(new Error('synthetic')); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + const failure = await screen.findByText( + 'We could not complete that people-finder search. No results are shown. Try again later.', + ); + expect(failure).toHaveAttribute('id', 'member-directory-search-failure'); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-query-help', + ); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-search-failure', + ); + }); + test('normalizes one explicit request and blocks duplicate submissions while pending', async () => { const pending = deferred<{ schemaVersion: 1; results: [] }>(); (searchMemberDirectory as jest.Mock).mockReturnValue(pending.promise); @@ -260,6 +306,79 @@ describe('Admin People finder', () => { .toHaveTextContent('No photo'); }); + test('announces non-empty completion politely without exposing a result total', async () => { + (searchMemberDirectory as jest.Mock).mockResolvedValue({ + schemaVersion: 1, + results: [ + { entryRef: ENTRY_REF, displayName: 'Synthetic Runner', photo: null }, + { entryRef: SECOND_ENTRY_REF, displayName: 'Second Synthetic', photo: null }, + ], + }); + renderDirectory(); + + submitSearch(); + + const completion = await screen.findByText( + 'Search complete. Matching result cards are available below.', + ); + expect(completion).toHaveAttribute('role', 'status'); + expect(completion).toHaveAttribute('aria-live', 'polite'); + expect(completion).toHaveAttribute('aria-atomic', 'true'); + expect(completion).not.toHaveTextContent(/\b2\b|\btwo\b/i); + const results = screen.getByRole('list', { + name: 'Opted-in People finder results', + }); + expect(results).toBeInTheDocument(); + expect(within(results).getAllByRole('listitem')).toHaveLength(2); + expect(screen.queryByText(/\b2 results\b/i)).not.toBeInTheDocument(); + }); + + test('keeps connected and preview search controls contained and touch-sized on narrow screens', () => { + const connected = renderDirectory(); + + const connectedInput = queryInput(); + const connectedButton = screen.getByRole('button', { name: 'Search' }); + expect(connectedInput).toHaveClass('min-h-11', 'min-w-0', 'w-full', 'max-w-full'); + expect(connectedButton).toHaveClass('min-h-11', 'w-full', 'sm:w-auto'); + expect(connectedInput.closest('form')).toHaveClass('min-w-0'); + connected.unmount(); + + renderDirectory({ backendAvailable: false }); + const previewInput = queryInput(); + const previewButton = screen.getByRole('button', { name: 'Search' }); + expect(previewInput).toBeDisabled(); + expect(previewInput).toHaveClass('min-h-11', 'min-w-0', 'w-full', 'max-w-full'); + expect(previewButton).toBeDisabled(); + expect(previewButton).toHaveClass('min-h-11', 'w-full', 'sm:w-auto'); + expect(previewInput.closest('form')).toHaveClass('min-w-0'); + }); + + test('uses scoped explicit colors instead of unavailable Tailwind palette utilities', () => { + renderDirectory(); + + expect(queryInput()).toHaveClass('member-directory-admin__input'); + expect(queryInput().closest('form')).toHaveClass( + 'member-directory-admin__search', + ); + expect(screen.getByRole('button', { name: 'Search' })).toHaveClass( + 'member-directory-admin__button', + ); + + const css = readFileSync( + join(__dirname, '../../account/Account.css'), + 'utf8', + ); + expect(css).toMatch( + /\.member-directory-admin__search,[\s\S]*background:\s*#f9fafb;/, + ); + expect(css).toMatch( + /\.member-directory-admin__input\s*\{[\s\S]*color:\s*#111827;[\s\S]*background:\s*#fff;/, + ); + expect(css).toMatch( + /\.member-directory-admin__button\s*\{[\s\S]*color:\s*#fff;[\s\S]*background:\s*#1e40af;/, + ); + }); + test('clears earlier results as soon as the next input changes', async () => { (searchMemberDirectory as jest.Mock).mockResolvedValue({ schemaVersion: 1, diff --git a/src/pages/admin/members/AdminMemberDirectory.tsx b/src/pages/admin/members/AdminMemberDirectory.tsx index 565230bb..9cabb4ea 100644 --- a/src/pages/admin/members/AdminMemberDirectory.tsx +++ b/src/pages/admin/members/AdminMemberDirectory.tsx @@ -4,6 +4,7 @@ import React, { } from 'react'; import { Link } from 'react-router-dom'; import SEO from '../../../components/SEO'; +import '../../account/Account.css'; import MEMBER_DIRECTORY_BACKEND_AVAILABLE from '../../../services/account/memberDirectoryAvailability'; import { useServiceLocator } from '../../../services/ServiceLocatorContext'; import { @@ -40,7 +41,7 @@ type SearchState = function PhotoFallback({ displayName }: { displayName: string }) { return (
@@ -134,11 +135,16 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { } const pending = state.phase === 'pending'; + const queryDescriptionIds = [ + 'member-directory-query-help', + validationMessage ? 'member-directory-query-validation' : null, + state.phase === 'unavailable' ? 'member-directory-search-failure' : null, + ].filter(Boolean).join(' '); return ( <>
@@ -149,13 +155,13 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { > Search opted-in people by name Enter the beginning of a name or name part. Search runs only when you choose Search. @@ -171,14 +177,15 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { autoComplete="off" spellCheck={false} aria-labelledby="member-directory-query-label" - aria-describedby="member-directory-query-help" - className="mt-3 block min-w-0 w-full max-w-full rounded border border-gray-500 bg-white px-3 py-2 text-gray-900" + aria-describedby={queryDescriptionIds} + aria-invalid={validationMessage !== null} + className="member-directory-admin__input mt-3 block min-h-11 min-w-0 w-full max-w-full rounded border border-gray-500 bg-white px-3 py-2 text-gray-900" /> @@ -187,7 +194,8 @@ function SearchAttempt({ app }: { app: FirebaseApp }) { {validationMessage && (
-

+

+ Search complete. Matching result cards are available below. +

+

Opted-in people

    (
event.preventDefault()} >
@@ -275,13 +292,13 @@ function MemberDirectoryPreview() { > Search opted-in people by name Name entry will be available after the protected backend is connected. @@ -295,14 +312,14 @@ function MemberDirectoryPreview() { autoComplete="off" aria-labelledby="member-directory-query-label-preview" aria-describedby="member-directory-search-preview-status member-directory-query-help-preview" - className="mt-3 block min-w-0 w-full max-w-full rounded border border-gray-500 bg-white px-3 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-60" + className="member-directory-admin__input mt-3 block min-h-11 min-w-0 w-full max-w-full rounded border border-gray-500 bg-white px-3 py-2 text-gray-900 disabled:cursor-not-allowed disabled:opacity-60" />