diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1c76622..80c5313 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -173,6 +173,8 @@ Exit gate: **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. +**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. + ### 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 18592a8..61e7027 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -101,6 +101,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-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. | +| 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. | | 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 97a7334..2f30f30 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -685,6 +685,8 @@ The preserved connected branch sends nothing while the person types. Search happ **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. +**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. + 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 a094598..d14de50 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -2434,13 +2434,19 @@ flowchart TD 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"] + Review["#629 connected photo review — SOURCE ONLY"] --> Draft["Generated valid image is read locally; centered-square draft says Selected photo — not uploaded yet"] + Draft -- "Cancel" --> Discard["Discard draft; create no request number; send nothing"] + Draft -- "Save only" --> Send["Create request number and send existing upload request"] + Send --> Readback["Refetch authoritative processed thumbnail or no-photo fallback"] + Discard --> Preserved + Readback --> Preserved 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, 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. +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. Officer review steps for the #621 frontend preview: @@ -2575,6 +2581,100 @@ The section purpose, approvers, stop conditions, undo path, and escalation roles **#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. +Officer review steps for MEMBERS-DIRECTORY-001F [#629] explicit photo review — connected source only, **NOT LIVE**: + +**Purpose:** let a backup officer verify that the preserved connected Account source holds a selected generated image locally for review and sends it only after explicit Save, without connecting the backend or changing production. + +**Approvers:** membership lead, privacy owner, and platform/security owner. + +**Prerequisites:** #627 is reviewed and merged. Ask the platform owner for the exact #629 source candidate and its generated-only test evidence. Keep the source-controlled availability value `false`. Use only a made-up account and a generated non-face JPG, PNG, or WebP in an isolated local test. Do not sign in to production, select a real photo, call 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 #629 issue, reviewed pull request, merge commit, and synthetic test record. +3. Confirm the source-controlled availability value remains byte-for-byte `false`. +4. Confirm the last verified production deployment remains inert #623 deploy `6a7e072f8f346b0008510d29`. +5. Confirm the synthetic test uses a generated non-face image. +6. Select one supported generated image in the isolated connected-source test. +7. Confirm the browser validates and reads the selection locally. +8. Confirm the pending draft is presented as a centered square. +9. Confirm the draft heading says **Selected photo — not uploaded yet**. +10. Confirm selection creates no request number. +11. Confirm selection calls no photo service. +12. Confirm the application never reads, retains, renders, logs, sends, or otherwise exposes the selected filename; opaque generated test-file names are allowed only as fixtures. +13. Confirm the current processed thumbnail or no-photo fallback remains visibly separate from the pending draft. +14. Select **Cancel selected photo**. +15. Confirm Cancel discards the pending draft. +16. Confirm Cancel creates no request number. +17. Confirm Cancel calls no photo service. +18. Select the generated image again. +19. Select **Save profile photo**. +20. Confirm Save creates the request number only after that action. +21. Confirm Save sends the exact selected content type and bytes once with the current revision. +22. Confirm successful Save refetches the current profile. +23. Confirm the refetched processed thumbnail or no-photo fallback replaces the local draft as the authoritative display. +24. Confirm Save leaves the independent finder choice unchanged. +25. Simulate request-number creation failure at Save. +26. Confirm the ready local draft remains available for a retry and nothing is sent. +27. Simulate one definitive rejected upload followed by successful profile readback. +28. Confirm the retryable local draft remains and the next Save uses the refetched revision. +29. Simulate an unknown upload result. +30. Confirm the local draft is discarded and the controls are hidden. +31. Repeat that discard-and-hide check for a successful upload followed by failed profile readback. +32. Repeat that discard-and-hide check for a definitive rejection followed by failed profile readback. +33. Save the independent finder choice while a draft is ready. +34. Confirm the local draft remains and its later Save uses the refreshed revision. +35. Test one unsupported generated file. +36. Confirm the unsupported file receives one fixed error associated with the file control. +37. Repeat the fixed-error check for an empty generated file. +38. Repeat the fixed-error check for an oversized generated file. +39. Repeat the fixed-error check for a simulated unreadable file. +40. Confirm each invalid or unreadable selection retains no draft and cannot be saved. +41. Repeat the fixed-error check for a browser-unrenderable generated image. +42. Confirm the unrenderable selection retains no image bytes and exposes no Save action. +43. Simulate failure to display the current saved thumbnail. +44. Confirm the saved-photo area says **Photo unavailable**. +45. Confirm **Remove profile photo** remains available. +46. Confirm a new saved-photo version gets a new display attempt. +47. Confirm an older read cannot render after reselection. +48. Confirm an older preview load or error cannot change a newer draft. +49. Confirm an older read cannot render after Cancel. +50. Confirm an older read cannot render after unmount. +51. Confirm an older read cannot render after the application changes. +52. Confirm an older read cannot render after the made-up account changes. +53. Confirm an older read cannot upload after any of those changes. +54. Confirm a named test announces that the ready local preview has not been uploaded. +55. Confirm **Cancel selected photo** is a native button that can receive keyboard focus. +56. Confirm that test returns focus to the persistent photo file control after Cancel. +57. Confirm that test announces that the selection was discarded and nothing was uploaded. +58. Confirm **Save profile photo** is a native button that can receive keyboard focus. +59. Confirm that test returns focus to the persistent Add or Replace photo file control after confirmed Save. +60. Confirm Save and Cancel are described for a screen reader. +61. Confirm Save and Cancel retain at least a 44-pixel height. +62. Confirm the saved state, pending draft, actions, and fixed errors fit at 320 pixels without horizontal overflow. +63. Confirm the default Account branch still accepts no file. +64. Confirm the default Account branch still creates no request number or directory service context. +65. Record whether the source changed. +66. Record the named test results. +67. Record whether the change merged. +68. Record whether the website was published. +69. Record the exact `runmprc.com` revision separately. +70. Record whether Firebase was deployed. +71. Record whether a provider was configured. +72. Record whether an account or sign-in state changed. +73. Record whether production data changed. +74. Record whether connected behavior became available. +75. Stop. Do not turn availability on, deploy a backend, publish the connected source, or use a real photo. + +**Expected result:** selecting a valid generated image produces one centered-square local draft labeled **Selected photo — not uploaded yet** and sends nothing. A polite status announces when that local preview is ready. Cancel discards that draft, sends nothing, announces the result, and returns focus to the persistent file control. Save alone creates a request number, sends the existing revisioned upload request, and then refetches the authoritative processed thumbnail or no-photo fallback; confirmed Save also returns focus to the persistent Add or Replace photo control. Request-number failure or definitive rejection with successful readback retains the retryable draft; unknown or failed readback discards it and hides controls. A visibility save preserves it and refreshes the revision used by its later Save. The application does not use or expose the filename, stale reads and image events are inert, finder visibility is unchanged, and invalid or unreadable selections retain no draft. Unrenderable bytes are discarded and cannot be saved. A failed saved-thumbnail display uses a version-reset **Photo unavailable** fallback while Remove remains available. Availability remains `false`; the live #623 preview and backend remain unchanged. Connected behavior is **NOT AVAILABLE YET**. + +**Stop conditions:** a real name or photo; a production sign-in; any photo request before explicit Save; a request number created by selection or Cancel; a filename read, retained, rendered, logged, sent, or exposed; an unknown-outcome or failed-readback draft retained; a retryable draft discarded after request-number failure or definitive rejection with successful readback; a stale draft rendered or sent after reselection, Cancel, unmount, application change, or account change; an invalid, unreadable, or unrenderable byte-bearing draft retained or savable; a saved-photo display failure that removes the Remove action or suppresses a new version; a photo change that alters finder visibility; a Firebase, Rule, index, Function, provider, account, sign-in, or production-data change; an availability flip; a connected website publication; or a claim that source, tests, or merge means the feature is live. + +**Success proof:** record the exact #629 issue, reviewed pull request and merge commit; green focused and full frontend checks; type-checking; diagnostic production build; unchanged lint baseline; workflow checks; diff-check; independent privacy/security, frontend/accessibility, and backup-officer GO reviews; and exact-main CI. Record the unchanged `false` availability value and unchanged #623 deploy separately. Record website publication, `runmprc.com` revision change, Firebase deployment, provider configuration, account/sign-in change, production-data action, and connected behavior as **not performed**. Final backend 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 or production-data undo is needed because #629 changes source only. + +**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. + **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 a488022..8cc7f23 100644 --- a/src/pages/account/Account.css +++ b/src/pages/account/Account.css @@ -289,6 +289,17 @@ min-width: 0; } +.member-directory-profile__current-photo { + display: flex; + flex: 0 0 8rem; + flex-direction: column; + align-items: center; + gap: 0.5rem; + max-width: 100%; + color: #111827; + text-align: center; +} + .member-directory-profile__photo img, .member-directory-profile__placeholder { width: 8rem; @@ -341,6 +352,53 @@ font: inherit; } +.member-directory-profile__draft { + display: flex; + min-width: 0; + margin: 0; + padding: 1rem; + flex-direction: column; + align-items: center; + gap: 0.75rem; + color: #111827; + background: #eff6ff; + border: 2px solid #1e40af; + border-radius: 0.5rem; + text-align: center; +} + +.member-directory-profile__draft h3 { + margin: 0; + color: #111827; + font-size: 1rem; +} + +.member-directory-profile__draft-review { + display: grid; + min-width: 0; + place-items: center; + gap: 0.5rem; +} + +.member-directory-profile__draft-image { + box-sizing: border-box; + width: 8rem; + height: 8rem; + max-width: 100%; + border: 2px solid #6b7280; + border-radius: 0.5rem; + object-fit: cover; + object-position: center; +} + +.member-directory-profile__draft-actions { + display: flex; + max-width: 100%; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; +} + .member-directory-profile__visibility { padding-top: 1rem; border-top: 1px solid #d1d5db; @@ -418,6 +476,15 @@ .member-directory-profile__placeholder { max-width: 100%; } + + .member-directory-profile__draft { + padding: 0.75rem; + } + + .member-directory-profile__draft-actions, + .member-directory-profile__draft-actions button { + width: 100%; + } } @media (min-width: 640px) { diff --git a/src/pages/account/MemberDirectoryProfile.test.tsx b/src/pages/account/MemberDirectoryProfile.test.tsx index 6fc5e43..e7601d0 100644 --- a/src/pages/account/MemberDirectoryProfile.test.tsx +++ b/src/pages/account/MemberDirectoryProfile.test.tsx @@ -66,6 +66,48 @@ function deferred() { return { promise, reject, resolve }; } +function installDeferredFileReader() { + const OriginalFileReader = globalThis.FileReader; + const readers: Array<{ + complete: (bytes: string, type?: string) => void; + fail: () => void; + }> = []; + class DeferredFileReader { + result: string | null = null; + + onload: (() => void) | null = null; + + onerror: (() => void) | null = null; + + onabort: (() => void) | null = null; + + readAsDataURL(file: File) { + readers.push({ + complete: (bytes, type = file.type) => { + this.result = `data:${type};base64,${btoa(bytes)}`; + this.onload?.(); + }, + fail: () => this.onerror?.(), + }); + } + } + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + writable: true, + value: DeferredFileReader, + }); + return { + readers, + restore() { + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + writable: true, + value: OriginalFileReader, + }); + }, + }; +} + function renderProfile({ firebaseApp = app, uid = 'synthetic-user', @@ -192,7 +234,7 @@ describe('My Account member directory profile', () => { fireEvent.change(file, { target: { - files: [new File(['synthetic'], 'synthetic.png', { type: 'image/png' })], + files: [new File(['synthetic'], 'fixture-001.png', { type: 'image/png' })], }, }); fireEvent.click(checkbox); @@ -257,7 +299,7 @@ describe('My Account member directory profile', () => { expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); }); - test('uploads a valid image with exact bytes and refetches without changing visibility', async () => { + test('MEMBERS-DIRECTORY-001F previews a valid image locally before an explicit save', async () => { const refreshed = { ...DEFAULT_PROFILE, revision: 1, @@ -269,10 +311,31 @@ describe('My Account member directory profile', () => { .mockResolvedValueOnce(refreshed); renderProfile(); const input = await screen.findByLabelText('Add profile photo'); - const file = new File(['synthetic pixels'], 'runner.png', { type: 'image/png' }); + const file = new File(['synthetic pixels'], 'fixture-002.png', { type: 'image/png' }); fireEvent.change(input, { target: { files: [file] } }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + expect(preview).toHaveAttribute( + 'src', + `data:image/png;base64,${btoa('synthetic pixels')}`, + ); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeDisabled(); + fireEvent.load(preview); + + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeEnabled(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Selected photo preview is ready. It has not been uploaded.', + ); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + expect(document.body).not.toHaveTextContent('fixture-002.png'); + + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { requestId: REQUEST_ID, expectedRevision: 0, @@ -286,10 +349,72 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('status')).toHaveTextContent( 'Profile photo saved. Your officer finder setting did not change.', ); + expect(screen.getByLabelText('Replace profile photo')).toHaveFocus(); expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); }); - test('replaces an existing photo without changing visibility', async () => { + test('MEMBERS-DIRECTORY-001F cancels a selected photo without creating a request or calling a service', async () => { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + + fireEvent.change(input, { + target: { + files: [new File(['synthetic pixels'], 'fixture-003.png', { + type: 'image/png', + })], + }, + }); + + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Cancel selected photo' })); + + expect(screen.getByRole('img', { name: 'No profile photo' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); + expect(document.body).not.toHaveTextContent('fixture-003.png'); + expect(input).toHaveFocus(); + expect(screen.getByRole('status')).toHaveTextContent( + 'Selected photo discarded. Nothing was uploaded.', + ); + }); + + test('MEMBERS-DIRECTORY-001F keeps a replacement draft local and restores the saved photo on Cancel', async () => { + (getMyMemberDirectoryProfile as jest.Mock).mockResolvedValue(PROFILE_WITH_PHOTO); + renderProfile(); + const input = await screen.findByLabelText('Replace profile photo'); + const current = screen.getByRole('img', { name: 'Your current profile thumbnail' }); + + fireEvent.change(input, { + target: { + files: [new File(['local replacement'], 'fixture-004.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + + expect(current).toHaveAttribute('src', `data:image/webp;base64,${PHOTO.base64Data}`); + fireEvent.click(screen.getByRole('button', { name: 'Cancel selected photo' })); + + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Your current profile thumbnail' })) + .toHaveAttribute('src', `data:image/webp;base64,${PHOTO.base64Data}`); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('fixture-004.png'); + }); + + test('MEMBERS-DIRECTORY-001F replaces an existing photo without changing visibility', async () => { const replacement = { ...PROFILE_WITH_PHOTO, revision: 5, @@ -300,18 +425,33 @@ describe('My Account member directory profile', () => { .mockResolvedValueOnce(replacement); renderProfile(); const input = await screen.findByLabelText('Replace profile photo'); - const file = new File(['replacement pixels'], 'replacement.webp', { + const file = new File(['replacement pixels'], 'fixture-005.webp', { type: 'image/webp', }); fireEvent.change(input, { target: { files: [file] } }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + expect(screen.getByRole('img', { name: 'Your current profile thumbnail' })) + .toHaveAttribute('src', `data:image/webp;base64,${PHOTO.base64Data}`); + expect(screen.getByRole('heading', { + level: 3, + name: 'Selected photo — not uploaded yet', + })).toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { requestId: REQUEST_ID, expectedRevision: 4, contentType: 'image/webp', base64Data: btoa('replacement pixels'), })); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); expect(setMyMemberDirectoryVisibility).not.toHaveBeenCalled(); expect(await screen.findByRole('img', { name: 'Your current profile thumbnail' })) .toHaveAttribute('src', `data:image/webp;base64,${replacement.photo.base64Data}`); @@ -320,24 +460,24 @@ describe('My Account member directory profile', () => { test.each([ [ 'unsupported type', - () => new File(['synthetic'], 'runner.gif', { type: 'image/gif' }), + () => new File(['synthetic'], 'fixture-006.gif', { type: 'image/gif' }), /choose a JPG, PNG, or WebP image/i, ], [ 'oversized image', () => new File( [new Uint8Array((2 * 1024 * 1024) + 1)], - 'runner.png', + 'fixture-007.png', { type: 'image/png' }, ), /2 MiB or smaller/i, ], [ 'empty image', - () => new File([], 'runner.webp', { type: 'image/webp' }), + () => new File([], 'fixture-008.webp', { type: 'image/webp' }), /non-empty image/i, ], - ])('rejects an %s before reading or uploading', async (_label, makeFile, message) => { + ])('MEMBERS-DIRECTORY-001F rejects an %s before reading or uploading', async (_label, makeFile, message) => { renderProfile(); const input = await screen.findByLabelText('Add profile photo'); @@ -354,6 +494,204 @@ describe('My Account member directory profile', () => { expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(1); }); + test('MEMBERS-DIRECTORY-001F keeps a valid draft when the chooser closes without selecting a file', async () => { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['synthetic pixels'], 'fixture-009.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + + fireEvent.change(input, { target: { files: [] } }); + + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeEnabled(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + }); + + test('MEMBERS-DIRECTORY-001F does not read or render a selected local filename', async () => { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + const file = new File(['synthetic pixels'], 'fixture-010.png', { type: 'image/png' }); + Object.defineProperty(file, 'name', { + configurable: true, + get() { + throw new Error('filename-access-canary'); + }, + }); + + fireEvent.change(input, { target: { files: [file] } }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + + expect(preview).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent('filename-access-canary'); + expect(document.body).not.toHaveTextContent('fixture-010.png'); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + }); + + test('MEMBERS-DIRECTORY-001F cancels during a deferred read and makes its late completion inert', async () => { + const deferredReader = installDeferredFileReader(); + try { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['late bytes'], 'fixture-011.png', { type: 'image/png' })], + }, + }); + expect(screen.getByRole('status')).toHaveTextContent( + 'Preparing selected photo preview...', + ); + fireEvent.click(screen.getByRole('button', { name: 'Cancel selected photo' })); + + await act(async () => deferredReader.readers[0].complete('late bytes')); + + expect(screen.getByRole('img', { name: 'No profile photo' })).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('MEMBERS-DIRECTORY-001F keeps only the newest selection when an older read finishes late', async () => { + const deferredReader = installDeferredFileReader(); + try { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['old bytes'], 'fixture-012.png', { type: 'image/png' })], + }, + }); + fireEvent.change(input, { + target: { + files: [new File(['new bytes'], 'fixture-013.webp', { type: 'image/webp' })], + }, + }); + + await act(async () => deferredReader.readers[1].complete('new bytes')); + const preview = screen.getByRole('img', { name: 'Selected profile photo preview' }); + expect(preview).toHaveAttribute( + 'src', + `data:image/webp;base64,${btoa('new bytes')}`, + ); + fireEvent.load(preview); + await act(async () => deferredReader.readers[0].complete('old bytes')); + + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute('src', `data:image/webp;base64,${btoa('new bytes')}`); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('MEMBERS-DIRECTORY-001F invalidates an older pending read when a newer selection is invalid', async () => { + const deferredReader = installDeferredFileReader(); + try { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['old bytes'], 'fixture-014.png', { type: 'image/png' })], + }, + }); + fireEvent.change(input, { + target: { + files: [new File(['invalid bytes'], 'fixture-015.gif', { type: 'image/gif' })], + }, + }); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Choose a JPG, PNG, or WebP image.', + ); + + await act(async () => deferredReader.readers[0].complete('old bytes')); + + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('MEMBERS-DIRECTORY-001F rejects an unreadable image without creating a request or retaining a draft', async () => { + const deferredReader = installDeferredFileReader(); + try { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['synthetic pixels'], 'fixture-016.png', { + type: 'image/png', + })], + }, + }); + await act(async () => deferredReader.readers[0].fail()); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'We could not read that image. Choose the file again.', + ); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body).not.toHaveTextContent('fixture-016.png'); + } finally { + deferredReader.restore(); + } + }); + + test('MEMBERS-DIRECTORY-001F rejects a browser-unrenderable image and removes its byte-bearing draft', async () => { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['not renderable'], 'fixture-017.png', { + type: 'image/png', + })], + }, + }); + fireEvent.error(await screen.findByRole('img', { + name: 'Selected profile photo preview', + })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'That image could not be displayed. Choose another image.', + ); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input.getAttribute('aria-describedby')).toContain( + 'member-directory-action-error', + ); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + expect(document.body.innerHTML).not.toContain(btoa('not renderable')); + expect(document.body).not.toHaveTextContent('fixture-017.png'); + }); + test('turns the finder on separately and refetches the authoritative setting', async () => { const searchableProfile = { ...DEFAULT_PROFILE, @@ -509,6 +847,80 @@ describe('My Account member directory profile', () => { expect(document.body).not.toHaveTextContent('synthetic crypto unavailable'); }); + test('MEMBERS-DIRECTORY-001F retains a decoded photo draft when request-ID creation fails and allows retry', async () => { + (createMemberDirectoryRequestId as jest.Mock) + .mockImplementationOnce(() => { + throw new Error('synthetic crypto unavailable'); + }) + .mockReturnValueOnce(REQUEST_ID); + const refreshed = { + ...DEFAULT_PROFILE, + revision: 1, + hasPhoto: true, + photo: PHOTO, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockResolvedValueOnce(refreshed); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['retry bytes'], 'fixture-018.png', { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + const save = screen.getByRole('button', { name: 'Save profile photo' }); + + fireEvent.click(save); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not safely start that change/i, + ); + expect(save).toBeEnabled(); + expect(preview).toBeInTheDocument(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + + fireEvent.click(save); + + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1)); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { + requestId: REQUEST_ID, + expectedRevision: 0, + contentType: 'image/png', + base64Data: btoa('retry bytes'), + }); + expect(await screen.findByRole('img', { name: 'Your current profile thumbnail' })) + .toBeInTheDocument(); + }); + + test('MEMBERS-DIRECTORY-001F guards rapid Save clicks before creating a second request or service call', async () => { + const request = deferred(); + (setMyMemberDirectoryPhoto as jest.Mock).mockReturnValueOnce(request.promise); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['rapid bytes'], 'fixture-019.png', { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + const save = screen.getByRole('button', { name: 'Save profile photo' }); + + fireEvent.click(save); + fireEvent.click(save); + + expect(createMemberDirectoryRequestId).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(save).toBeDisabled(); + }); + test('keeps an existing opt-in removable but hidden when the display name is ineligible', async () => { const hiddenProfile = { ...PROFILE_WITH_PHOTO, @@ -597,6 +1009,45 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('status')).not.toHaveTextContent('Profile photo removed.'); }); + test('MEMBERS-DIRECTORY-001F falls back when a saved thumbnail cannot decode and resets for a new version', async () => { + const newPhoto = { + ...PHOTO, + base64Data: btoa('new synthetic processed thumbnail'), + version: '22222222-2222-4222-8222-222222222222', + }; + const refreshed = { + ...PROFILE_WITH_PHOTO, + revision: 5, + searchableByOfficers: false, + photo: newPhoto, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(PROFILE_WITH_PHOTO) + .mockResolvedValueOnce(refreshed); + renderProfile(); + const saved = await screen.findByRole('img', { + name: 'Your current profile thumbnail', + }); + + fireEvent.error(saved); + + expect(screen.getByRole('img', { + name: 'Saved profile photo could not be displayed', + })).toHaveTextContent('Photo unavailable'); + expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); + + fireEvent.click(screen.getByRole('checkbox')); + + const reset = await screen.findByRole('img', { + name: 'Your current profile thumbnail', + }); + expect(reset).toHaveAttribute( + 'src', + `data:image/webp;base64,${newPhoto.base64Data}`, + ); + expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); + }); + test('blocks parallel mutations, then hides all controls after an unknown mutation outcome', async () => { const request = deferred(); (setMyMemberDirectoryVisibility as jest.Mock).mockReturnValueOnce(request.promise); @@ -650,6 +1101,218 @@ describe('My Account member directory profile', () => { expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); }); + test('MEMBERS-DIRECTORY-001F retains an upload draft after definitive rejection and retries with the refetched revision', async () => { + const rejected = { code: 'functions/aborted' }; + const current = { ...DEFAULT_PROFILE, revision: 7 }; + const saved = { + ...current, + revision: 8, + hasPhoto: true, + photo: { ...PHOTO, version: '22222222-2222-4222-8222-222222222222' }, + }; + (setMyMemberDirectoryPhoto as jest.Mock) + .mockRejectedValueOnce(rejected) + .mockResolvedValueOnce({}); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockResolvedValueOnce(current) + .mockResolvedValueOnce(saved); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['retained bytes'], 'fixture-020.png', { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /rejected before it was saved/i, + ); + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute('src', `data:image/png;base64,${btoa('retained bytes')}`); + const retry = screen.getByRole('button', { name: 'Save profile photo' }); + expect(retry).toBeEnabled(); + + fireEvent.click(retry); + + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(2)); + expect(setMyMemberDirectoryPhoto).toHaveBeenNthCalledWith(2, app, { + requestId: REQUEST_ID, + expectedRevision: 7, + contentType: 'image/png', + base64Data: btoa('retained bytes'), + }); + expect(await screen.findByRole('img', { name: 'Your current profile thumbnail' })) + .toBeInTheDocument(); + }); + + test('MEMBERS-DIRECTORY-001F discards an upload draft when definitive rejection cannot be refetched', async () => { + const rejected = { code: 'functions/failed-precondition' }; + (setMyMemberDirectoryPhoto as jest.Mock).mockRejectedValueOnce(rejected); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === rejected, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockRejectedValueOnce(new Error('synthetic private readback detail')); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['discard after rejection'], 'fixture-021.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not load your profile photo and officer finder settings/i, + ); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add profile photo')).not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain(btoa('discard after rejection')); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test.each([ + ['upload outcome is unknown', false], + ['post-upload refetch fails', true], + ])('MEMBERS-DIRECTORY-001F discards the local draft and hides controls when the %s', async (_label, resolves) => { + if (resolves) { + (setMyMemberDirectoryPhoto as jest.Mock).mockResolvedValueOnce({}); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockRejectedValueOnce(new Error('synthetic private readback detail')); + } else { + (setMyMemberDirectoryPhoto as jest.Mock) + .mockRejectedValueOnce(new Error('synthetic private outcome detail')); + } + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['discarded bytes'], 'fixture-022.png', { type: 'image/png' })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not confirm that change/i, + ); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(screen.queryByLabelText('Add profile photo')).not.toBeInTheDocument(); + expect(document.body.innerHTML).not.toContain(btoa('discarded bytes')); + expect(document.body).not.toHaveTextContent('synthetic private'); + }); + + test('MEMBERS-DIRECTORY-001F treats a definitive-looking error from post-upload readback as unknown', async () => { + const readbackFailure = { code: 'functions/permission-denied' }; + (setMyMemberDirectoryPhoto as jest.Mock).mockResolvedValueOnce({}); + (isDefinitiveMemberDirectoryRejection as jest.Mock).mockImplementation( + (error) => error === readbackFailure, + ); + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockRejectedValueOnce(readbackFailure); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['uncertain saved bytes'], 'fixture-023.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + + expect(await screen.findByRole('alert')).toHaveTextContent( + /could not confirm that change/i, + ); + expect(screen.queryByRole('img', { name: 'Selected profile photo preview' })) + .not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save profile photo' })) + .not.toBeInTheDocument(); + expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + expect(document.body.innerHTML).not.toContain(btoa('uncertain saved bytes')); + expect(document.body).not.toHaveTextContent('permission-denied'); + expect(document.body).not.toHaveTextContent('rejected before it was saved'); + }); + + test('MEMBERS-DIRECTORY-001F preserves a local draft across a visibility save and uploads with its refreshed revision', async () => { + const visibilityUpdated = { + ...DEFAULT_PROFILE, + revision: 1, + searchableByOfficers: true, + }; + const photoUpdated = { + ...visibilityUpdated, + revision: 2, + hasPhoto: true, + photo: PHOTO, + }; + (getMyMemberDirectoryProfile as jest.Mock) + .mockResolvedValueOnce(DEFAULT_PROFILE) + .mockResolvedValueOnce(visibilityUpdated) + .mockResolvedValueOnce(photoUpdated); + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['visibility-safe bytes'], 'fixture-024.png', { + type: 'image/png', + })], + }, + }); + const preview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.load(preview); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(await screen.findByText('Officer finder is on.')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save profile photo' })); + + await waitFor(() => expect(setMyMemberDirectoryPhoto).toHaveBeenCalledWith(app, { + requestId: REQUEST_ID, + expectedRevision: 1, + contentType: 'image/png', + base64Data: btoa('visibility-safe bytes'), + })); + expect(setMyMemberDirectoryVisibility).toHaveBeenCalledTimes(1); + expect(setMyMemberDirectoryPhoto).toHaveBeenCalledTimes(1); + }); + test('reloads settings before allowing a retry after an unknown outcome', async () => { const current = { ...DEFAULT_PROFILE, revision: 1, searchableByOfficers: true }; (getMyMemberDirectoryProfile as jest.Mock) @@ -737,31 +1400,9 @@ describe('My Account member directory profile', () => { ['application', otherApp, 'synthetic-user'], ['account', app, 'other-synthetic-user'], ])( - 'makes a deferred file read inert after the %s changes', + 'MEMBERS-DIRECTORY-001F 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, - }); + const deferredReader = installDeferredFileReader(); try { (getMyMemberDirectoryProfile as jest.Mock) @@ -774,14 +1415,15 @@ describe('My Account member directory profile', () => { target: { files: [new File( ['deferred synthetic pixels'], - 'deferred.png', + 'fixture-025.png', { type: 'image/png' }, )], }, }); expect(await screen.findByRole('status')).toHaveTextContent( - 'Saving profile photo...', + 'Preparing selected photo preview...', ); + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); view.rerender( { name: 'Your current profile thumbnail', })).toBeInTheDocument(); - await act(async () => { - if (completeRead === null) throw new Error('synthetic deferred read missing'); - completeRead(); - await Promise.resolve(); - }); + await act(async () => deferredReader.readers[0].complete( + 'deferred synthetic pixels', + )); expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); expect(getMyMemberDirectoryProfile).toHaveBeenCalledTimes(2); @@ -809,15 +1449,71 @@ describe('My Account member directory profile', () => { 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, - }); + deferredReader.restore(); } }, ); + test('MEMBERS-DIRECTORY-001F makes a deferred file read inert after unmount', async () => { + const deferredReader = installDeferredFileReader(); + try { + const view = renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['late unmounted bytes'], 'fixture-026.png', { + type: 'image/png', + })], + }, + }); + expect(screen.getByRole('button', { name: 'Cancel selected photo' })) + .toBeEnabled(); + + view.unmount(); + await act(async () => deferredReader.readers[0].complete('late unmounted bytes')); + + expect(createMemberDirectoryRequestId).not.toHaveBeenCalled(); + expect(setMyMemberDirectoryPhoto).not.toHaveBeenCalled(); + } finally { + deferredReader.restore(); + } + }); + + test('MEMBERS-DIRECTORY-001F ignores stale preview load and error events after reselection', async () => { + renderProfile(); + const input = await screen.findByLabelText('Add profile photo'); + fireEvent.change(input, { + target: { + files: [new File(['old render bytes'], 'fixture-027.png', { type: 'image/png' })], + }, + }); + const oldPreview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + fireEvent.change(input, { + target: { + files: [new File(['new render bytes'], 'fixture-028.webp', { type: 'image/webp' })], + }, + }); + const newPreview = await screen.findByRole('img', { + name: 'Selected profile photo preview', + }); + expect(newPreview).toHaveAttribute( + 'src', + `data:image/webp;base64,${btoa('new render bytes')}`, + ); + + fireEvent.load(oldPreview); + fireEvent.error(oldPreview); + + expect(screen.getByRole('img', { name: 'Selected profile photo preview' })) + .toHaveAttribute('src', `data:image/webp;base64,${btoa('new render bytes')}`); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeDisabled(); + fireEvent.load(newPreview); + expect(screen.getByRole('button', { name: 'Save profile photo' })).toBeEnabled(); + }); + test('ignores an initial load completion after unmount', async () => { const load = deferred(); (getMyMemberDirectoryProfile as jest.Mock).mockReturnValueOnce(load.promise); @@ -847,7 +1543,7 @@ describe('My Account member directory profile', () => { expect(screen.getByRole('button', { name: 'Remove profile photo' })).toBeEnabled(); }); - test('keeps the light panel readable and contains the native file input at 320px', () => { + test('MEMBERS-DIRECTORY-001F keeps the photo review contained at 320px', () => { const css = readFileSync(join(__dirname, 'Account.css'), 'utf8'); expect(css).toMatch(/\.member-directory-profile h2\s*\{[\s\S]*color:\s*#111827;/); @@ -866,9 +1562,27 @@ describe('My Account member directory profile', () => { 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__draft\s*\{[\s\S]*min-width:\s*0;[\s\S]*margin:\s*0;/, + ); expect(css).toMatch( /\.member-directory-profile__visibility label\s*\{[\s\S]*min-height:\s*2\.75rem;/, ); + expect(css).toMatch( + /\.member-directory-profile__draft\s*\{[\s\S]*min-width:\s*0;[\s\S]*color:\s*#111827;[\s\S]*background:\s*#eff6ff;[\s\S]*border:\s*2px solid #1e40af;/, + ); + expect(css).toMatch( + /\.member-directory-profile__draft-image\s*\{[\s\S]*width:\s*8rem;[\s\S]*height:\s*8rem;[\s\S]*max-width:\s*100%;/, + ); + expect(css).toMatch( + /\.member-directory-profile__draft-image\s*\{[\s\S]*object-fit:\s*cover;[\s\S]*object-position:\s*center;/, + ); + expect(css).toMatch( + /\.member-directory-profile button\s*\{[\s\S]*min-height:\s*2\.75rem;/, + ); expect(css).toMatch(/@media \(max-width:\s*359px\)/); + expect(css).toMatch( + /@media \(max-width:\s*359px\)[\s\S]*\.member-directory-profile__draft-actions,\s*\.member-directory-profile__draft-actions button\s*\{[\s\S]*width:\s*100%;/, + ); }); }); diff --git a/src/pages/account/MemberDirectoryProfile.tsx b/src/pages/account/MemberDirectoryProfile.tsx index 90ff040..b09623d 100644 --- a/src/pages/account/MemberDirectoryProfile.tsx +++ b/src/pages/account/MemberDirectoryProfile.tsx @@ -203,6 +203,20 @@ type ActionError = { message: string; }; +type PhotoDraft = + | { + phase: 'reading'; + identity: symbol; + } + | { + phase: 'preview'; + identity: symbol; + contentType: MemberDirectoryUploadType; + base64Data: string; + dataUrl: string; + renderState: 'loading' | 'ready'; + }; + type MutationConfirmation = ( profile: MemberDirectoryProfileData, ) => string; @@ -241,6 +255,36 @@ function isAcceptedUploadType(value: string): value is MemberDirectoryUploadType return MEMBER_DIRECTORY_UPLOAD_TYPES.some((type) => type === value); } +function SavedMemberDirectoryPhoto({ + photo, +}: { + photo: NonNullable; +}) { + const [failed, setFailed] = useState(false); + + if (failed) { + return ( +
+ Photo unavailable +
+ ); + } + + return ( + Your current profile thumbnail setFailed(true)} + /> + ); +} + function readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -271,9 +315,13 @@ function MemberDirectoryProfileAttempt({ const [state, setState] = useState({ phase: 'loading' }); const [reloadAttempt, setReloadAttempt] = useState(0); const [actionError, setActionError] = useState(null); + const [photoDraft, setPhotoDraft] = useState(null); const lifetimeRef = useRef(null); const loadRef = useRef(null); const mutationRef = useRef(null); + const photoReadRef = useRef(null); + const photoInputRef = useRef(null); + const focusPhotoInputAfterSaveRef = useRef(false); useEffect(() => { const lifetime = Symbol('member-directory-lifetime'); @@ -282,16 +330,27 @@ function MemberDirectoryProfileAttempt({ if (lifetimeRef.current === lifetime) lifetimeRef.current = null; loadRef.current = null; mutationRef.current = null; + photoReadRef.current = null; + focusPhotoInputAfterSaveRef.current = false; }; }, []); + useEffect(() => { + if (state.phase !== 'ready' || !focusPhotoInputAfterSaveRef.current) return; + focusPhotoInputAfterSaveRef.current = false; + photoInputRef.current?.focus(); + }, [state.phase]); + useEffect(() => { const lifetime = lifetimeRef.current; const load = Symbol('member-directory-load'); let active = true; loadRef.current = load; mutationRef.current = null; + photoReadRef.current = null; + focusPhotoInputAfterSaveRef.current = false; setActionError(null); + setPhotoDraft(null); setState({ phase: 'loading' }); async function loadProfile() { @@ -355,11 +414,6 @@ function MemberDirectoryProfileAttempt({ ) { try { await mutate(); - if (!mutationIsCurrent(start)) return; - const profile = await getMyMemberDirectoryProfile(app); - if (!mutationIsCurrent(start)) return; - mutationRef.current = null; - setState({ phase: 'ready', profile, confirmation: confirmation(profile) }); } catch (error) { if (!mutationIsCurrent(start)) return; if (isDefinitiveMemberDirectoryRejection(error)) { @@ -376,12 +430,37 @@ function MemberDirectoryProfileAttempt({ } catch { if (!mutationIsCurrent(start)) return; mutationRef.current = null; + photoReadRef.current = null; + setPhotoDraft(null); setActionError(null); setState({ phase: 'unavailable' }); } return; } mutationRef.current = null; + photoReadRef.current = null; + setPhotoDraft(null); + setActionError(null); + setState({ phase: 'unknown' }); + return; + } + + if (!mutationIsCurrent(start)) return; + try { + const profile = await getMyMemberDirectoryProfile(app); + if (!mutationIsCurrent(start)) return; + mutationRef.current = null; + if (start.action !== 'visibility') { + photoReadRef.current = null; + setPhotoDraft(null); + } + if (start.action === 'upload') focusPhotoInputAfterSaveRef.current = true; + setState({ phase: 'ready', profile, confirmation: confirmation(profile) }); + } catch { + if (!mutationIsCurrent(start)) return; + mutationRef.current = null; + photoReadRef.current = null; + setPhotoDraft(null); setActionError(null); setState({ phase: 'unknown' }); } @@ -437,12 +516,30 @@ function MemberDirectoryProfileAttempt({ ); } + function cancelSelectedPhoto() { + photoReadRef.current = null; + setPhotoDraft(null); + setActionError((current) => (current?.control === 'file' ? null : current)); + setState((current) => (current.phase === 'ready' + ? { + ...current, + confirmation: 'Selected photo discarded. Nothing was uploaded.', + } + : current)); + photoInputRef.current?.focus(); + } + async function handlePhotoSelection(event: React.ChangeEvent) { const input = event.currentTarget; const file = input.files?.[0] || null; input.value = ''; - setActionError(null); if (file === null) return; + photoReadRef.current = null; + setPhotoDraft(null); + setActionError(null); + setState((current) => (current.phase === 'ready' + ? { ...current, confirmation: null } + : current)); const { type: contentType } = file; if (!isAcceptedUploadType(contentType)) { setActionError({ @@ -461,35 +558,92 @@ function MemberDirectoryProfileAttempt({ return; } - let requestId: string; + const lifetime = lifetimeRef.current; + if (lifetime === null) return; + const identity = Symbol('member-directory-photo-read'); + photoReadRef.current = identity; + setPhotoDraft({ phase: 'reading', identity }); + let base64Data: string; try { - requestId = createMemberDirectoryRequestId(); + base64Data = await readFileAsBase64(file); } catch { + if ( + lifetimeRef.current !== lifetime + || photoReadRef.current !== identity + ) return; + photoReadRef.current = null; + setPhotoDraft(null); setActionError({ control: 'file', - invalid: false, - message: REQUEST_UNAVAILABLE_MESSAGE, + invalid: true, + message: 'We could not read that image. Choose the file again.', }); return; } + if ( + lifetimeRef.current !== lifetime + || photoReadRef.current !== identity + ) return; + + setPhotoDraft({ + phase: 'preview', + identity, + contentType, + base64Data, + dataUrl: `data:${contentType};base64,${base64Data}`, + renderState: 'loading', + }); + } - const start = startMutation('upload'); - if (start === null) return; - let base64Data: string; + function handleDraftRender(identity: symbol, rendered: boolean) { + if (photoReadRef.current !== identity) return; + if (!rendered) { + photoReadRef.current = null; + setPhotoDraft(null); + setActionError({ + control: 'file', + invalid: true, + message: 'That image could not be displayed. Choose another image.', + }); + return; + } + setPhotoDraft((current) => { + if ( + current?.phase !== 'preview' + || current.identity !== identity + ) return current; + return { + ...current, + renderState: 'ready', + }; + }); + setActionError((current) => (current?.control === 'file' ? null : current)); + } + + function saveSelectedPhoto() { + if ( + state.phase !== 'ready' + || photoDraft?.phase !== 'preview' + || photoDraft.renderState !== 'ready' + || photoReadRef.current !== photoDraft.identity + || mutationRef.current !== null + ) return; + + let requestId: string; try { - base64Data = await readFileAsBase64(file); + requestId = createMemberDirectoryRequestId(); } catch { - if (!mutationIsCurrent(start)) return; - mutationRef.current = null; - setState({ phase: 'ready', profile: start.profile, confirmation: null }); setActionError({ control: 'file', - invalid: true, - message: 'We could not read that image. Choose the file again.', + invalid: false, + message: REQUEST_UNAVAILABLE_MESSAGE, }); return; } - if (!mutationIsCurrent(start)) return; + + const start = startMutation('upload'); + if (start === null) return; + const { contentType, base64Data } = photoDraft; finishMutation( start, @@ -559,27 +713,29 @@ function MemberDirectoryProfileAttempt({ return (
- {profile.photo ? ( - Your current profile thumbnail - ) : ( -
- No photo -
- )} +
+ Current saved photo + {profile.photo ? ( + + ) : ( +
+ No photo +
+ )} +
+ {photoDraft && ( +
+
+

+ Selected photo — not uploaded yet +

+

+ Review this local preview. Choose Save profile photo to upload + it, or Cancel selected photo to discard it without sending it. +

+
+ + {photoDraft.phase === 'reading' && ( +

+ Preparing selected photo preview... +

+ )} + + {photoDraft.phase === 'preview' && ( +
+ {/* The wording distinguishes this unsaved photo from the saved thumbnail. */} + {/* eslint-disable-next-line jsx-a11y/img-redundant-alt */} + Selected profile photo preview handleDraftRender(photoDraft.identity, true)} + onError={() => handleDraftRender(photoDraft.identity, false)} + /> + {photoDraft.renderState === 'loading' && ( +

+ Checking selected photo preview... +

+ )} + {photoDraft.renderState === 'ready' && ( +

+ Selected photo preview is ready. It has not been uploaded. +

+ )} +
+ )} + +
+ {photoDraft.phase !== 'reading' && ( + + )} + +
+
+ )} +