import { useRef, useState } from 'react'; import { AcceptedAgreements } from './AcceptedAgreements'; import type { Patient } from 'fhir/r4'; import { Ban, Loader2 } from 'lucide-react'; import './PatientSettingsPage.css'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { useCommunicationPreferenceService } from '../../../services/CommunicationPreferenceService.tsx'; import { usePatientServiceContext } from '../../../services/PatientService.tsx'; import { HttpError } from '../../../services/HttpService.tsx'; import { Checkbox } from '../../../components/ui/checkbox'; import { Input } from '../../../components/ui/input'; import { Label } from '../../../components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select'; import { CHANNELS, PATIENT_SMS_NUMBER_EXTENSION_URL, carrierBlockScope, getAllCommPreferences, hasCarrierRestriction, readCommPreference, getEmailVerification, getSmsVerification, listEmailAddresses, listEmailChoices, listMobileNumbers, normaliseEmail, setCommPreference, setContactEmail, setEmailVerifiedLocally, setSmsNumber, } from '../../../config/communicationPreferences'; import type { CommChannel, CommPreference, CommPreferenceInput } from '../../../config/communicationPreferences'; import { CommunicationConsentDialog } from './CommunicationConsentDialog'; import { relayServerFailure, ownFailureSentence } from '../../../utils/fetchFailureMessage'; import { saveOverCurrent } from '../../../utils/saveOverCurrent'; import { isRecordChanged, RECORD_CHANGED_MESSAGE, RELOAD_DISCARDS_MESSAGE } from '../../../utils/recordChanged'; import { isSaveRetired, isSaveTimedOut, useSaveScope } from '../../../utils/saveOperation'; import { emailSelectionOf, smsSelectionOf } from '../../../utils/contactSelectionOwned'; import { describeServerRefusal } from '../../../utils/serverRefusalMessage'; import { formatDate } from '../../../utils/dateUtils'; import { getLoginEmail } from '../../../utils/cognitoIdentity'; import { describeProxyCannotChangePreferences } from '../../../utils/proxyFormAuthority'; import { olderThan } from '../../../utils/versionOrder'; // S2b - every failure the two verification endpoints can return maps to its own copy, never // the server's raw message. 503 is the one that MUST NOT read like a wrong code: the server refuses to // open the gate when it cannot durably audit why, and a "your code was wrong" message there would send // a patient into a pointless retry loop on a code that was never bad. 429 gets a rate message with no // suggestion of an instant retry. See task-7-brief.md's failure table. const describeSmsRequestError = (err: unknown): string => { if (err instanceof HttpError) { if (err.status === 429) return 'Too many attempts. Try again later.'; if (err.status === 503) return 'Verification is temporarily unavailable. Please try again shortly.'; if (err.status === 502) return 'Could not send the verification code. Try again later.'; // 409 IS THE ONE THAT MUST CARRY THE SERVER'S OWN WORDS, and it is the exception to the line // above about never showing them. The others are failures - a rate limit, an outage, a relay // that broke - where the server's text is a status line and the only useful thing to say is // whether retrying helps. A 409 here is our own handler REFUSING, deliberately and // indefinitely: texting is not switched on for this practice, so no code can be sent today, // tomorrow or after any number of presses. // // IT USED TO BE A 502 AND THIS FUNCTION STILL ANSWERS ONE, which is why that branch stays. // The api began refusing with 409 and a sentence; this mapper had no case for it, so the // refusal fell through to "Please try again" and the patient was still invited to retry what // cannot succeed. The server's half of that fix reached staging and the browser never showed // it - found by pressing the button, not by reading the diff. if (err.status === 409) return describeServerRefusal(err, 'Could not send a verification code'); } // AND THE SAME ANSWER WITHOUT THE instanceof, WHICH IS NOT BELT AND BRACES. // Walked on the built mount 2026-09-15: the request answered 409, this file's 409 branch was in the // shipped bundle, and the catch-all below still rendered - so the guard above did not admit the // error. Why is not yet known: two module instances would give HttpError two identities, and a // rejection that never carried a status would do it too. Both are the same fix from here. // Reading the status off the shape answers the patient either way, and it cannot make any existing // branch wrong - nothing above matches unless the status is already the one it names. const status = (err as { status?: unknown } | null | undefined)?.status; if (status === 409) return describeServerRefusal(err, 'Could not send a verification code'); return 'Could not start verification. Please try again.'; }; // 409 IS THE ONE TERMINAL VERIFY FAILURE. verifyFlow.ts DELETES the pending record when the selected // number changed after the code was sent, so the pendingVerificationId held in state is dead and every // subsequent Confirm answers the opaque 400 - permanently, with no on-screen escape. This page's // otherwise-correct "keep the pending state on error" rule is exactly what made it a dead end. See // `isNumberChanged` and confirmSmsVerification: the message alone is not the fix. const SMS_NUMBER_CHANGED = 'The selected number changed. Request a new code for the number shown above.'; const describeSmsVerifyError = (err: unknown): string => { if (err instanceof HttpError) { if (err.status === 409) return SMS_NUMBER_CHANGED; if (err.status === 429) return 'Too many attempts. Try again later.'; if (err.status === 503) return 'Verification is temporarily unavailable. Please try again shortly.'; if (err.status === 502) return 'Could not send the verification code. Try again later.'; if (err.status === 400) return 'That code is not valid or has expired. Please request a new one.'; } return 'That code could not be verified. Please try again.'; }; const isNumberChanged = (err: unknown): boolean => err instanceof HttpError && err.status === 409; // S3, Task 8 - the mobile CARRIER blocked this channel (an inbound STOP, or a send failure // that detected the same thing). `PUT /R4/patient-communication-preferences` answers 409 when a // client tries to set enabled:true for a channel in that state (put.ts's carrier-block guard). This // page's own toggle no longer offers that action once `disabledReason: 'carrierOptOut'` is stamped // (see the carrierBlocked branch in the render below) - but the guard is also reachable in the race // where the block lands server-side between this page loading (still showing "off, no reason") and // the patient's own save landing. That save must show OUR copy, not a relay of the server's message // (which reads fine today but is not a contract this page should depend on wording-for-wording). // R3 - the answer is always "text START from your phone"; no control here may attempt the re-enable // itself, and this copy explains why the save just failed rather than looking like a generic error. const CARRIER_BLOCKED_SAVE_ERROR = 'This number was blocked by your mobile carrier. Text START from your phone to re-enable it — ' + 'it cannot be turned back on here.'; // KEYED ON THE BODY, NOT ON THE BARE STATUS. 409 is not a synonym for "carrier block": // utils/error-handler.ts relays an upstream HealthLake status and body VERBATIM, so a write conflict on // this very route arrives as a 409 as well. Keying on the status alone told a patient whose save merely // collided that their carrier had blocked their phone and that they must go text START - false, and // unactionable. The server's own carrier-block 409 already carries `disabledReason` // (handlers/Patient/CommunicationPreferences/put.ts), so that field is the signal and the status is // only the envelope. const isCarrierBlockConflict = (err: unknown): boolean => err instanceof HttpError && err.status === 409 && (err.body as { disabledReason?: string } | undefined)?.disabledReason === 'carrierOptOut'; // The stored-state copy shown in place of the toggle once `disabledReason: 'carrierOptOut'` is set. // // IT MUST NOT SAY "YOU TEXTED STOP". The field is named `carrierOptOut` and deliberately NOT named // for its detection mechanism, because the two paths that set it are genuinely different events: Path B // learns the block from a send-time ConflictException with NO inbound message at all, and on a SHARED // handset (the server's patientLookup returns EVERY patient on a number, by design) an inbound STOP may // have been sent by a spouse. Asserting the patient sent it puts back, in patient-facing text about a // consent revocation, exactly the claim the data model was shaped to avoid making. // // What IS true in every case, and what the patient can act on: their carrier is refusing texts to this // number, and a texted START from that handset is the only thing that lifts it. const carrierBlockedNotice = (disabledAt: string | undefined): string => 'Your mobile carrier is blocking text messages to this number' + (disabledAt ? `, as of ${formatDate(disabledAt)}` : '') + ". This can't be turned back on here. Text START from that phone to ask your carrier to lift this sender's block. " + 'START does not record a new agreement. Texts resume only if your original agreement still applies.'; // The SMS-LEG-ONLY counterpart. Shown ALONGSIDE a live checkbox, never in place of one. // // THE DIFFERENCE FROM THE NOTICE ABOVE IS THE WHOLE POINT. That one says "this can't be turned back // on here" because for a transport channel it genuinely cannot. For a CATEGORY channel spanning email // and SMS, the carrier has blocked only the texts - the setting itself is still fully the patient's, and // saying otherwise would strip their only way to turn marketing off (see carrierBlockScope). // // So this copy does two things the other must not: it scopes the block to TEXTS explicitly, and it says // out loud that the rest of the channel is unaffected - otherwise a patient reads "carrier is blocking" // and reasonably concludes the whole setting is dead. const smsLegBlockedNotice = (disabledAt: string | undefined): string => 'Your mobile carrier is blocking text messages to this number' + (disabledAt ? `, as of ${formatDate(disabledAt)}` : '') + '. Text START from that phone to lift the carrier block; this does not record a new agreement. Email is unaffected, and you can still turn this ' + 'setting off or on here at any time.'; // --------------------------------------------------------------------------- // - the CONTACT-EMAIL failure surface. // --------------------------------------------------------------------------- // THIS DELIBERATELY DIVERGES FROM describeSmsRequestError ABOVE, AND THE DIVERGENCE IS THE POINT. // That mapper answers every failure with copy of its own, and for the SMS pair that is defensible: its // refusals are few and its 400 is a single opaque "wrong code" the server never elaborates. // // The contact-email doors are not like that. They refuse with SEVERAL distinct, ACTIONABLE, states - // "Too many codes sent. Please try again later." (start cap), "No verifiable contact email is // selected" (the address is not on the profile), "That address cannot be verified" (undeliverable), // "The selected address changed. Start verification again.", and on the guardian door "That contact is // already the guardian's own...". Collapsing those into "Could not start verification. Please try // again." strips the one sentence that tells the patient what to DO, and sends them retrying against a // door the server has already told them is shut. So the SERVER'S MESSAGE IS WHAT THE PATIENT SEES. // // READ FROM `err.body`, NEVER `err.message`. HttpService builds `message` as // `Request failed: ${body.message}` (services/HttpService.tsx), so relaying `err.message` would put // that prefix in front of the patient. The parsed body is carried on HttpError precisely so callers // can use the fields rather than the lossy string. // // The fallback is a FLOOR, not a default: a network failure, an HTML error page, or any response with // no parseable message still has to say something. const serverMessage = (err: unknown): string | undefined => { if (!(err instanceof HttpError)) return undefined; const message = (err.body as { message?: unknown } | undefined)?.message; return typeof message === 'string' && message.trim() ? message.trim() : undefined; }; const describeEmailError = (err: unknown, fallback: string): string => serverMessage(err) ?? fallback; // The one TERMINAL verify failure, exactly as on the SMS side: verifyFlow.ts DELETES the pending // record when the selected address changed after the code was sent, so the pendingVerificationId held // in state is dead and every subsequent Confirm answers the opaque 400 - permanently, with no // on-screen escape. Surfacing the server's message is necessary but NOT sufficient; the pending state // has to be dropped too (see confirmEmailVerification). const isAddressChanged = (err: unknown): boolean => err instanceof HttpError && err.status === 409; // Sentinel select value for "Add a new number" -- distinct from any real digit string. const ADD_NEW_NUMBER = '__add_new__'; // The email counterpart. Distinct from any real address (no '@'), same role as ADD_NEW_NUMBER. const ADD_NEW_EMAIL = '__add_new_email__'; const formatPhoneForDisplay = (digits: string): string => digits.length === 10 ? `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}` : digits; // What each save depends on, compared with the record the choice on screen was made from before it is written. const smsOwned = (patient: Patient): string => JSON.stringify(smsSelectionOf(patient)); const emailOwned = (patient: Patient): string => JSON.stringify(emailSelectionOf(patient, { withVerification: false })); const RELOAD_FAILED = 'The record could not be read. Try Reload record again.'; const currentSmsDigits = (patient: Patient): string | undefined => patient.extension ?.find((e) => e.url === PATIENT_SMS_NUMBER_EXTENSION_URL) ?.extension?.find((c) => c.url === 'digits')?.valueString; // Patient-only settings page: communication preferences. Each channel is OFF by default; turning one ON requires // agreeing to the informed HIPAA/TCPA disclaimer (which records the version + timestamp). Reads the current state // off the patient's own Patient resource; writes one channel at a time via the sub-route. /** * - THE SUBJECT IS HANDED IN, and that is the whole fix. * * This component used to derive its patient from `currentUser.fhir`, which is ALWAYS the authenticated * user and does not move when a proxy switches in. While the banner read *"Viewing 's * account"*, the screen showed - and **saved** - the guardian's own notification preferences, SMS and * email verification state. * * THE SIBLING CARDS MAKE THE SEVERITY CLEAR: 506 refused loudly, 507 silently READ the wrong record, * and this one silently **WRITES** it. A guardian turning SMS off *for their child* turned it off **for * themselves**, and the child's routing was never touched. Both halves wrong, neither visible - and no * error anywhere, which is why using the product could not find it. * * TAKING THE SUBJECT AS A PROP RATHER THAN READING IT HERE IS DELIBERATE, and it is not style. Half the * state below is seeded by `useState(() =>...)` initialisers, which run **once, at mount**. An early return * inside this component would not re-seed them when the subject arrived - the guard has to sit where it * can decide whether to MOUNT this at all. `PatientSettingsPage` below is that guard. */ export function PatientSettingsPageForSubject({ patient, readOnlyReason }: { patient: Patient; readOnlyReason?: string }) { const { currentUser, setCurrentUser } = useAuthServiceContext(); const { putPreference, requestSmsVerification, verifySmsCode, requestEmailVerification, verifyEmailCode, } = useCommunicationPreferenceService(); const { updatePatient, fetchPatient } = usePatientServiceContext(); /** *, THE SECOND HALF - MAY WE MIRROR THIS SAVE INTO THE CACHED LOGIN RECORD? * * Five places below call `setCurrentUser(...)` to keep `currentUser.fhir` in step with a write, so * re-mounting this page re-seeds its toggles from a fresh value rather than a stale one. That is * correct **only when the record being written IS the authenticated user's**. * * WHILE ACTING AS A DEPENDENT IT IS NOT, and mirroring would stamp the DEPENDENT's preference onto * the GUARDIAN's cached Patient - writing the right record on the server and corrupting the wrong one * in memory. The card named the write; this is the copy of it nobody was looking at. * * Cached-only, so nothing is persisted wrongly - but it is what the guardian's own settings screen * reads next, so the wrongness is visible and outlives the save. */ const isOwnRecord = Boolean(patient.id) && patient.id === currentUser?.fhir?.id; const [prefs, setPrefs] = useState>>(() => patient ? getAllCommPreferences(patient) : {}, ); const preferenceRevision = useRef(0); const displayedPreferences = useRef(prefs); const retiredSmsDecisions = useRef(new Set()); const preferenceRecord = useRef(patient); const preferencePending = useRef(false); const preferenceDraft = useRef(false); const [dialogChannel, setDialogChannel] = useState(null); const [savingChannel, setSavingChannel] = useState(null); const [error, setError] = useState(null); // SMS number selection. Seeded from whatever digits are currently stored on the // patient-sms-number extension; falls back to unselected ('') when that number isn't among the // patient's current mobile telecom entries (or none has been chosen yet) -- selection re-derives // from telecom, same self-healing rule the server's resolveSmsNumber follows at send time. const [smsSelection, setSmsSelection] = useState(() => { if (!patient) return ''; const stored = currentSmsDigits(patient); return stored && listMobileNumbers(patient).includes(stored) ? stored : ''; }); const [smsNewNumber, setSmsNewNumber] = useState(''); const [smsSaving, setSmsSaving] = useState(false); const [smsError, setSmsError] = useState(null); // SMS number VERIFICATION. `smsVerifying` is true only for the duration of the // request/verify round trip -- not for the whole time the code-entry UI is open -- so it lines up // with `smsSaving`/`savingChannel` for the mutual-exclusion guard below. const [smsVerifying, setSmsVerifying] = useState(false); const [smsVerifyPending, setSmsVerifyPending] = useState<{ pendingVerificationId: string; maskedNumber: string } | null>(null); const [smsVerifyCode, setSmsVerifyCode] = useState(''); const [smsVerifyError, setSmsVerifyError] = useState(null); // WHAT A TEXT PROOF PROVED IS READ BACK, NOT ASSUMED. The server sends the code to the number stored when it is // asked, which may not be the one on screen (another writer can choose a new one first), and its answer names no // number. So after a proof the record is read again, and that is what is shown - never a stamp on the number this // page happened to display. The patient as handed in now, kept for the same reason: a reload never goes below it. const [readBack, setReadBack] = useState(null); const livePatient = useRef(patient); livePatient.current = patient; // --------------------------------------------------------------------------- // - the CONTACT-EMAIL half. Mirrors the SMS block above state for state. // --------------------------------------------------------------------------- // Seeded from whatever address is currently stored on the patient-email extension; falls back to // unselected when that address is no longer among the patient's email telecom entries, which is the // same self-healing rule the server's resolveEmailAddress applies at send time (it refuses an // address that has left the profile). const [emailSelection, setEmailSelection] = useState(() => { if (!patient) return ''; const stored = getEmailVerification(patient)?.address; return stored && listEmailAddresses(patient).includes(stored) ? stored : ''; }); const [emailNewAddress, setEmailNewAddress] = useState(''); const [emailSaving, setEmailSaving] = useState(false); const [emailError, setEmailError] = useState(null); const [emailVerifying, setEmailVerifying] = useState(false); const [emailVerifyPending, setEmailVerifyPending] = useState<{ pendingVerificationId: string; maskedAddress: string } | null>(null); const [emailVerifyCode, setEmailVerifyCode] = useState(''); const [emailVerifyError, setEmailVerifyError] = useState(null); // BOUND TO THE ADDRESS IT WAS PROVED FOR (the SMS side reads its proof back instead; see readBack). // This is an OPTIMISTIC stamp - it exists only to reflect a write the server has // already made before the parent re-renders this component with a fresh `patient` - and an // optimistic "verified" that is not tied to an address is exactly how a screen ends up showing // proven trust beside an inbox nobody proved. Clearing it on save (as SMS does) closes the common // path; carrying the address closes it structurally, including the paths a future edit invents. const [localEmailVerified, setLocalEmailVerified] = useState<{ address: string; verifiedAt: string } | null>(null); // THE RECORD EACH CHOICE ON SCREEN WAS MADE FROM. The number and address selections above are seeded once, at // mount, and a live update of `patient` does not re-seed them - so a save is checked against the record they // were seeded from, not against whatever `patient` is when Save is pressed (which would adopt a newer // selection silently and write the old choice over it). Only a save that lands, or the explicit "Reload record" // below, moves it. A refused, out-of-date or unconfirmed save leaves the choice `stale`: nothing more is saved // from it until the record is reloaded. const smsOpening = useRef(patient); const emailOpening = useRef(patient); const [smsStale, setSmsStale] = useState(false); const [emailStale, setEmailStale] = useState(false); // Each save belongs to this subject and the person signed in; another of either retires it, so an answer // for one never lands in another's cached record. const saveScope = useSaveScope(patient?.id); if (!patient) { return (
Communication settings are available on your patient account.
); } const installPreferences = (next: Partial>) => { const preceding = displayedPreferences.current.sms?.decision?.id ?? null; const incoming = next.sms?.decision?.id ?? null; if (preceding !== incoming) retiredSmsDecisions.current.add(preceding); displayedPreferences.current = next; setPrefs(next); }; const adoptPreferences = (fresh: Patient, revision: number, belongs: () => boolean) => { if (!belongs() || preferencePending.current || preferenceDraft.current || fresh.id !== patient.id || preferenceRevision.current !== revision) return; const newest = [livePatient.current, preferenceRecord.current].reduce( (held, known) => known.id === held.id && olderThan(held, known) ? known : held, fresh, ); const next = getAllCommPreferences(newest); if (retiredSmsDecisions.current.has(next.sms?.decision?.id ?? null)) return; preferenceRecord.current = newest; preferenceRevision.current += 1; installPreferences(next); return preferenceRevision.current; }; const save = async (channel: CommChannel, enabled: boolean) => { const meta = CHANNELS.find((c) => c.key === channel); if (!meta) return; // Part of this subject's page and this session: not sent, and not applied, once either has moved on. const operation = saveScope.start(); const revision = ++preferenceRevision.current; preferencePending.current = true; setSavingChannel(channel); setError(null); try { const preference: CommPreferenceInput = enabled ? { enabled: true, disclaimerVersion: meta.disclaimer.version, agreedAt: new Date().toISOString() } : { enabled: false }; if (enabled && channel === 'sms') preference.expectedDecisionId = prefs.sms?.decision?.id ?? null; const res = await operation.step(() => putPreference(channel, preference, operation.transport), true); if (preferenceRevision.current !== revision) return; const raw = res?.preferences; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Could not save your preference. Please try again.'); const authoritative = readCommPreference(raw[channel], patient.id, channel); const loaded = prefs[channel]; const retainsCarrier = authoritative && (!loaded?.carrierUnknown || authoritative.carrierUnknown === true) && (loaded?.carrierBlocks ?? []).every(prior => authoritative.carrierBlocks?.some(block => block.stop.handset === prior.stop.handset && block.stop.originator === prior.stop.originator && block.stop.stream === prior.stop.stream && Date.parse(block.stop.occurredAt) >= Date.parse(prior.stop.occurredAt) && (Date.parse(block.stop.occurredAt) > Date.parse(prior.stop.occurredAt) || block.stop.id === prior.stop.id))); const invalidSmsAnswer = channel === 'sms' && (!authoritative || !retainsCarrier || !authoritative.decision || authoritative.decision.enabled !== enabled || (enabled && (!authoritative.decision.originalConsent || hasCarrierRestriction(setCommPreference(patient, channel, authoritative), channel)))); if (!authoritative || authoritative.enabled !== enabled || invalidSmsAnswer) { throw new Error('Could not save your preference. Please try again.'); } const next: Partial> = {}; for (const row of CHANNELS) { if (raw[row.key] === undefined) continue; const parsed = readCommPreference(raw[row.key], patient.id, row.key); if (!parsed) throw new Error('Could not save your preference. Please try again.'); next[row.key] = parsed; } if (retiredSmsDecisions.current.has(next.sms?.decision?.id ?? null)) { throw new Error('Could not save your preference. Please try again.'); } installPreferences(next); // Keep the shared cached Patient (currentUser.fhir) in sync with the write. `prefs` is seeded from // the SUBJECT once, on mount - so without this, navigating away and back re-mounts the page and // re-seeds the toggles from the STALE pre-save Patient, showing the old state until a full reload // re-authenticates. setCommPreference mirrors the server's immutable merge byte-for-byte. setCurrentUser((prev) => operation.belongs() && preferenceRevision.current === revision && isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === patient.id ? { ...prev, fhir: setCommPreference(prev.fhir as Patient, channel, authoritative) } : prev, ); preferenceDraft.current = false; setDialogChannel(null); } catch (e) { if (preferenceRevision.current !== revision) return; if (isSaveRetired(e)) return; if (isSaveTimedOut(e)) { setError(ownFailureSentence(e)); } else if (isCarrierBlockConflict(e)) { setError(CARRIER_BLOCKED_SAVE_ERROR); } else { setError(relayServerFailure(e, 'Could not save your preference. Please try again.')); } } finally { if (operation.belongs() && preferenceRevision.current === revision) { preferencePending.current = false; setSavingChannel(null); } } }; const onToggle = (channel: CommChannel, checked: boolean) => { preferenceRevision.current += 1; if (checked) { // Turning ON requires consent first - open the disclaimer; the toggle stays off until they agree. preferenceDraft.current = true; setDialogChannel(channel); } else { void save(channel, false); } }; const dialogMeta = CHANNELS.find((c) => c.key === dialogChannel); const mobileNumbers = patient ? listMobileNumbers(patient) : []; const isAddingNew = smsSelection === ADD_NEW_NUMBER; const canSaveSmsNumber = !smsStale && (isAddingNew ? smsNewNumber.replace(/\D/g, '').length === 10 : smsSelection.length > 0); // The verification state of the patient's currently SELECTED (saved) number, from the newest record known here: // the one handed in, or the one read back after a proof (see readBack above). const persistedSmsVerification = getSmsVerification( readBack && readBack.id === patient?.id && !olderThan(readBack, patient) ? readBack : patient, ); // Shown only for the number this page's choice was made from: a proof of another number is never shown under it. const smsVerification = persistedSmsVerification && persistedSmsVerification.digits === currentSmsDigits(smsOpening.current) ? persistedSmsVerification : undefined; // Verify controls disable the number-save + channel toggles, and vice versa (mirrors the // smsSaving/savingChannel mutual exclusion above - both are full read-modify-writes, or in this // case a write, against the same Patient's extensions). // THE CONTACT-EMAIL WRITES JOIN THE SAME MUTUAL EXCLUSION, and not for tidiness: the email // address save is a full read-modify-write PUT of the SAME Patient the SMS save and the channel // toggles write. Two overlapping writes read the same stale base and the later one silently reverts // the other - the exact bug the sms/channel exclusion above was added for, which a second // read-modify-write on the page would otherwise reintroduce from the other direction. const smsControlsBusy = smsSaving || savingChannel !== null || emailSaving || emailVerifying; // The persisted verification state for the currently selected address, folding in a just-completed // verify() before the parent has re-rendered this component with a fresh `patient`. // // THE ADDRESS EQUALITY IS LOAD-BEARING, not a belt-and-braces check. The optimistic stamp is only // ever allowed to describe the address it was actually proved for; without this the stamp would // survive onto whatever address happened to be selected next and paint it verified. const persistedEmailVerification = getEmailVerification(patient); const emailVerification = persistedEmailVerification && localEmailVerified && localEmailVerified.address === persistedEmailVerification.address ? { ...persistedEmailVerification, verified: true, verifiedAt: localEmailVerified.verifiedAt } : persistedEmailVerification; // The signed-in account's own email on its own record; the username tag can be the account UUID. const signInEmail = ((isOwnRecord ? currentUser?.cognito?.email : undefined) || getLoginEmail(patient))?.toLowerCase(); const emailAddresses = listEmailChoices(patient, signInEmail); const isAddingNewEmail = emailSelection === ADD_NEW_EMAIL; const chosenEmail = normaliseEmail(isAddingNewEmail ? emailNewAddress : emailSelection); // Deliberately shallow: a serious address check belongs on the server (the request door runs // isValidEmail and refuses undeliverable addresses with its own message), and a strict client regex // would reject legitimate addresses the server accepts. This only stops an obviously empty or // '@'-less save reaching the network. const canSaveContactEmail = !emailStale && chosenEmail.includes('@') && !chosenEmail.startsWith('@') && !chosenEmail.endsWith('@'); const emailControlsBusy = emailSaving || savingChannel !== null || smsSaving || smsVerifying; // VERIFICATION IS NECESSARY, NOT SUFFICIENT. The server's eligibleChannels texts a number only // when `sms` AND `appointmentReminders` consent are both on AND the number is verified - the same // conjunction as config/communicationPreferences.ts::isSmsDeliveryConsented, evaluated here against // `prefs` rather than `patient` so a toggle flipped on this page updates the notice immediately. // This block renders off `c.key === 'sms'`, never off `enabled`, so without the notice below its // copy ("before we start texting", "reminders will be sent by email instead of text") reads to a // patient with the channel off as though verifying were the last step. const smsDeliveryConsented = prefs.sms?.enabled === true && prefs.appointmentReminders?.enabled === true; // Reload record, after a refused save: the record as it is now, read fresh within its own bounded operation, seeds // the choice again (discarding the one on screen, as the page says first) and becomes what the next save is // checked against. const reloadChoice = async ( setBusy: (busy: boolean) => void, setError: (error: string | null) => void, opening: Patient | undefined, reseed: (fresh: Patient) => void, ) => { if (!patient?.id) return; const operation = saveScope.start(); const revision = preferenceRevision.current; setBusy(true); setError(null); try { const fresh = await operation.step(() => fetchPatient(patient.id as string, false, undefined, true, operation.transport)); if (!fresh) throw new Error('This record could not be read.'); operation.admit(); // A read can lag: the choice is never seeded from an older version than the page holds now, than the // opening it replaces, or than a proof read back. const newest = [livePatient.current, opening, readBack, preferenceRecord.current].reduce( (newest, known) => (known && known.id === newest.id && olderThan(newest, known) ? known : newest), fresh, ); reseed(newest); const adoptedRevision = adoptPreferences(newest, revision, operation.belongs); setCurrentUser((prev) => operation.belongs() && adoptedRevision !== undefined && preferenceRevision.current === adoptedRevision && !olderThan(fresh, preferenceRecord.current) && isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === fresh.id && !olderThan(fresh, prev.fhir) ? { ...prev, fhir: fresh } : prev, ); } catch (e) { if (isSaveRetired(e)) return; setError(RELOAD_FAILED); } finally { if (operation.belongs()) setBusy(false); } }; const reloadSms = () => reloadChoice(setSmsSaving, setSmsError, smsOpening.current, (fresh) => { const stored = currentSmsDigits(fresh); setSmsSelection(stored && listMobileNumbers(fresh).includes(stored) ? stored : ''); setSmsNewNumber(''); smsOpening.current = fresh; setSmsStale(false); }); const reloadEmail = () => reloadChoice(setEmailSaving, setEmailError, emailOpening.current, (fresh) => { const stored = getEmailVerification(fresh)?.address; setEmailSelection(stored && listEmailAddresses(fresh).includes(stored) ? stored : ''); setEmailNewAddress(''); emailOpening.current = fresh; setEmailStale(false); }); const saveSmsNumber = async () => { if (!patient?.id || !canSaveSmsNumber) return; const operation = saveScope.start(); const revision = preferenceRevision.current; setSmsSaving(true); setSmsError(null); try { const chosen = isAddingNew ? smsNewNumber : smsSelection; // Base the write on a FRESHLY FETCHED Patient, never `currentUser.fhir` - that is the // snapshot taken at LOGIN. This save is a full PUT of the whole resource, so writing from // the snapshot silently reverts every change made to the record since the patient signed // in: a staff correction to their name or address, and (the sharp one) a consent decision // the server already has. `forceReload` because PatientService caches Patients, and a // cached read is the stale snapshot again. Mirrors the same re-read discipline // FhirPatientDisplay.tsx applies around its own PUT. // // A failed read throws to the catch below: nothing is written and the patient is told. // Refusing to write is the correct outcome - a stale write is the bug. // ...and only while the number shown here is still the one on record: saved over the record as it is // now, conditional on its version, so a change made elsewhere meanwhile is never written over. const opening = smsOpening.current; const result = await saveOverCurrent({ operation, read: () => fetchPatient(opening.id as string, false, undefined, true, operation.transport), stillHolds: (current) => current.id === opening.id && smsOwned(current) === smsOwned(opening), apply: (current) => setSmsNumber(current, chosen, { addToProfile: isAddingNew }), write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation.transport), }); const adoptedRevision = adoptPreferences(result, revision, operation.belongs); smsOpening.current = result; setCurrentUser((prev) => // An older answer (another save here landed after it) is not installed over a newer record. operation.belongs() && adoptedRevision !== undefined && preferenceRevision.current === adoptedRevision && !olderThan(result, preferenceRecord.current) && isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === result.id && !olderThan(result, prev.fhir) ? { ...prev, fhir: result } : prev, ); const savedDigits = currentSmsDigits(result) ?? chosen.replace(/\D/g, ''); setSmsSelection(savedDigits); setSmsNewNumber(''); // setSmsNumber always writes verified:false server-side for the newly-saved number (trust // never carries over from a different number), so any in-progress or completed verification // for the PREVIOUS number is stale - drop it rather than let it linger on the new one. setSmsVerifyPending(null); setSmsVerifyCode(''); setSmsVerifyError(null); } catch (e) { if (isSaveRetired(e)) return; if (isSaveTimedOut(e)) { setSmsError(ownFailureSentence(e)); if (e.mayHaveSaved) setSmsStale(true); return; } if (isRecordChanged(e)) { setSmsError(ownFailureSentence(e)); setSmsStale(true); return; } setSmsError(relayServerFailure(e, 'Could not save your number. Please try again.')); } finally { if (operation.belongs()) setSmsSaving(false); } }; // Issues (or resends) a code to the patient's currently selected number. No separate resend // control - calling this again while a verification is already open resends against it server-side. const startSmsVerification = async () => { const operation = saveScope.start(); setSmsVerifying(true); setSmsVerifyError(null); try { const res = await operation.step(() => requestSmsVerification(operation.transport), true); setSmsVerifyPending({ pendingVerificationId: res.pendingVerificationId, maskedNumber: res.maskedNumber }); setSmsVerifyCode(''); } catch (e) { if (isSaveRetired(e)) return; setSmsVerifyError(isSaveTimedOut(e) ? ownFailureSentence(e) : describeSmsRequestError(e)); } finally { if (operation.belongs()) setSmsVerifying(false); } }; const confirmSmsVerification = async () => { if (!smsVerifyPending || smsVerifyCode.trim().length === 0) return; const operation = saveScope.start(); const revision = preferenceRevision.current; setSmsVerifying(true); setSmsVerifyError(null); try { await operation.step(() => verifySmsCode(smsVerifyPending.pendingVerificationId, smsVerifyCode.trim(), operation.transport), true); setSmsVerifyPending(null); setSmsVerifyCode(''); // What was proved is read back from the record (see readBack): the number stored, and whether it is // verified. Verification proves control of the handset, not consent to be texted; the SMS channel toggle // (patient-communication-preferences) is untouched. const fresh = await operation.step(() => fetchPatient(patient.id as string, false, undefined, true, operation.transport)) .catch((readError: unknown) => { if (isSaveRetired(readError)) throw readError; return undefined; }); if (!fresh) { setSmsVerifyError('The code was accepted, but the record could not be read again to show it. Reload the page to see it.'); return; } const adoptedRevision = adoptPreferences(fresh, revision, operation.belongs); setReadBack(fresh); setCurrentUser((prev) => operation.belongs() && adoptedRevision !== undefined && preferenceRevision.current === adoptedRevision && !olderThan(fresh, preferenceRecord.current) && isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === fresh.id && !olderThan(fresh, prev.fhir) ? { ...prev, fhir: fresh } : prev, ); // Someone else chose another number before the code was asked for: the choice on screen is not what is // stored, so nothing more is saved from it until it is reloaded. if (currentSmsDigits(fresh) !== currentSmsDigits(smsOpening.current)) { setSmsStale(true); setSmsError(RECORD_CHANGED_MESSAGE); } } catch (e) { if (isSaveRetired(e)) return; if (isSaveTimedOut(e)) { setSmsVerifyError(ownFailureSentence(e)); return; } if (isNumberChanged(e)) { // 409 - the record is gone server-side, so this code entry can never succeed. Drop it // and go back to the request step, where "Verify this number" starts a clean one for // whatever number is selected now. setSmsVerifyPending(null); setSmsVerifyCode(''); } // Otherwise leave pending + code entry usable - a wrong/expired code (or a transient/rate // failure) should let the patient try again without re-requesting a fresh code. setSmsVerifyError(describeSmsVerifyError(e)); } finally { if (operation.belongs()) setSmsVerifying(false); } }; // --------------------------------------------------------------------------- // - choosing and proving the contact address. // --------------------------------------------------------------------------- const saveContactEmail = async () => { if (!patient?.id || !canSaveContactEmail) return; const operation = saveScope.start(); const revision = preferenceRevision.current; setEmailSaving(true); setEmailError(null); try { // FRESHLY FETCHED, never `currentUser.fhir` - that is the LOGIN snapshot, and this save is // a full PUT of the whole resource, so writing from it silently reverts every change made to // the record since the patient signed in. Same reasoning, same `forceReload`, as // saveSmsNumber above. A failed read throws to the catch below: nothing is written and the // patient is told, which is the correct outcome - a stale write is the bug. // `addToProfile` unconditionally: the server's resolveEmailAddress refuses an address that // is not ALSO on telecom as system:'email', so an extension written without it produces a // selection the request door answers "No verifiable contact email is selected" for, with the // address plainly on screen. setContactEmail de-dupes, so re-saving an existing address adds // nothing. Saved over the record as it is now, only while the address shown here still is. const opening = emailOpening.current; const result = await saveOverCurrent({ operation, read: () => fetchPatient(opening.id as string, false, undefined, true, operation.transport), stillHolds: (current) => current.id === opening.id && emailOwned(current) === emailOwned(opening), apply: (current) => setContactEmail(current, chosenEmail, { addToProfile: true }), write: (candidate, basedOn) => updatePatient(candidate, basedOn, operation.transport), }); const adoptedRevision = adoptPreferences(result, revision, operation.belongs); emailOpening.current = result; setCurrentUser((prev) => // An older answer (another save here landed after it) is not installed over a newer record. operation.belongs() && adoptedRevision !== undefined && preferenceRevision.current === adoptedRevision && !olderThan(result, preferenceRecord.current) && isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === result.id && !olderThan(result, prev.fhir) ? { ...prev, fhir: result } : prev, ); setEmailSelection(getEmailVerification(result)?.address ?? chosenEmail); setEmailNewAddress(''); // setContactEmail always writes verified:false for the newly-saved address (trust never // carries over from a different one), so any in-progress or completed verification is now // stale - drop it rather than let it linger and describe the new address. setLocalEmailVerified(null); setEmailVerifyPending(null); setEmailVerifyCode(''); setEmailVerifyError(null); } catch (e) { if (isSaveRetired(e)) return; if (isSaveTimedOut(e)) { setEmailError(ownFailureSentence(e)); if (e.mayHaveSaved) setEmailStale(true); return; } if (isRecordChanged(e)) { setEmailError(ownFailureSentence(e)); setEmailStale(true); return; } setEmailError(describeEmailError(e, 'Could not save your email address. Please try again.')); } finally { if (operation.belongs()) setEmailSaving(false); } }; // Issues (or resends) a code to the currently selected address. No separate resend route - calling // this again while a verification is open resends against it server-side, and once the resend // allowance is spent the server replaces the record and charges a start slot instead. const startEmailVerification = async () => { const operation = saveScope.start(); setEmailVerifying(true); setEmailVerifyError(null); try { const res = await operation.step(() => requestEmailVerification(operation.transport), true); setEmailVerifyPending({ pendingVerificationId: res.pendingVerificationId, maskedAddress: res.maskedAddress }); setEmailVerifyCode(''); } catch (e) { if (isSaveRetired(e)) return; setEmailVerifyError(isSaveTimedOut(e) ? ownFailureSentence(e) : describeEmailError(e, 'Could not start verification. Please try again.')); } finally { if (operation.belongs()) setEmailVerifying(false); } }; const confirmEmailVerification = async () => { if (!emailVerifyPending || emailVerifyCode.trim().length === 0) return; const operation = saveScope.start(); setEmailVerifying(true); setEmailVerifyError(null); try { const res = await operation.step(() => verifyEmailCode(emailVerifyPending.pendingVerificationId, emailVerifyCode.trim(), operation.transport), true); // Stamped against the address the server just proved - see localEmailVerified's note on why // this carries the address rather than a bare timestamp. if (persistedEmailVerification) { setLocalEmailVerified({ address: persistedEmailVerification.address, verifiedAt: res.verifiedAt }); // R5 - keep the cached Patient's patient-email extension in step with a write the // server already made, and NOTHING else. Verification proves control of the mailbox, not // consent to be emailed; the email channel toggle is untouched. setCurrentUser((prev) => isOwnRecord && prev?.fhir?.resourceType === 'Patient' && prev.fhir.id === patient.id ? { ...prev, fhir: setEmailVerifiedLocally(prev.fhir as Patient, persistedEmailVerification.address, res.verifiedAt) } : prev, ); } setEmailVerifyPending(null); setEmailVerifyCode(''); } catch (e) { if (isSaveRetired(e)) return; if (isSaveTimedOut(e)) { setEmailVerifyError(ownFailureSentence(e)); return; } if (isAddressChanged(e)) { // 409 - the record is gone server-side, so this code entry can never succeed. Drop it and // go back to the request step, where "Verify this address" starts a clean one. The // message alone would leave the patient typing into a box that is already dead. setEmailVerifyPending(null); setEmailVerifyCode(''); } // Otherwise leave the pending state and the code entry usable - a wrong/expired code, or a // transient failure, should let the patient try again without re-requesting. setEmailVerifyError(describeEmailError(e, 'That code could not be verified. Please try again.')); } finally { if (operation.belongs()) setEmailVerifying(false); } }; return (

Settings

{/* The page stack spaces the blocks; the label and its lede stay one head, 6px apart. */}

Communication preferences

Choose how Akashic can contact you. Email and text messages are not secure, so turn a channel on only if you accept that. You can change these anytime.

{error &&

{error}

} {readOnlyReason && (

{readOnlyReason}

)} {/* A disabled fieldset disables every control inside it, the Radix checkboxes and selects included, and `:disabled` styling follows - so a helper is never asked to accept a channel's risk on the patient's behalf for a change the server refuses. */}

These communication preferences apply across all your practices.

    {CHANNELS.map((c) => { const pref = prefs[c.key]; const enabled = pref?.enabled ?? false; // S3 - a channel the mobile CARRIER blocked (STOP, or a detected send // failure) is a different state than the patient turning it off in the portal // (no `disabledReason` stamped). R3: no control here may offer to re-enable // it - the checkbox below is not merely disabled-looking, it is not rendered at // all. Leaving it clickable would create a real loop: turning it "on" still // fails at the carrier, and the server's own reconciliation path flips it // straight back off (Path B), an unexplained toggle that refuses to stay on. The // server also refuses the write with 409 (see `isCarrierBlockConflict` above) - // this is the primary defense, that is defense in depth. Only the patient's own // texted START may lift the block; we never un-revoke on their behalf. // // - THE STAMP'S REACH DEPENDS ON THE CHANNEL, so this is two states, // not one. Everything above is true for a TRANSPORT channel (`sms`), where the // carrier really has made the channel unsendable. `promotional` is a CATEGORY // spanning email and SMS: the carrier blocked the texts and has no say over the // email leg, so `enabled` stays the patient's and the checkbox MUST remain. // Hiding it there removes their only control to turn marketing OFF - at exactly // the moment they have signalled they want less contact - and tells them to text // START from a handset they may no longer own. See carrierBlockScope(). const carrierStamped = hasCarrierRestriction(setCommPreference(patient, c.key, pref ?? { enabled: false }), c.key); const carrierBlocked = carrierStamped && carrierBlockScope(c.key) === 'whole-channel'; const carrierBlockedSmsLegOnly = carrierStamped && carrierBlockScope(c.key) === 'sms-leg-only'; return (
  • {carrierBlocked ? (
    {/* --------------------------------------------------------------- * Card 127 — the CONTACT EMAIL, beside the channel it belongs to, * exactly as the SMS number sits beside `sms` below. * * THIS IS NOT THE LOGIN EMAIL and the copy must never suggest it * is. Changing where you sign in is a Cognito-bound flow of its own * (card 086) that sends its code to the OLD address on purpose; this * is a CONTACT address on the record, and a patient who confused the * two would think they had just moved their login. * --------------------------------------------------------------- */} {c.key === 'email' && patient && (

    You’ll be asked to confirm with a code.

    {isAddingNewEmail && ( { setEmailNewAddress(e.target.value); setEmailError(null); }} disabled={emailControlsBusy || emailVerifying} className="w-52" /> )} {/* The accessible name is deliberately NOT "Save": this page * already carries a Save for the SMS number, and two controls * named identically on one screen are ambiguous to anyone * navigating by name. The visible label stays "Save" (green, * per the button convention). */}
    {emailError &&

    {emailError}

    } {emailStale && (
    {RELOAD_DISCARDS_MESSAGE}{' '}
    )} {/* R5 — A VERIFIED CONTACT PROVES CONTROL, NEVER CONSENT, and * this says so in BOTH states on purpose. AFTER confirming is * precisely when a patient would otherwise sit waiting for mail * that consent was never given for — the same reasoning the SMS * block's "will not start text messages" notice carries, and the * reason it too renders whether or not the number is verified. * Shown only while the channel is off: once the patient HAS * enabled it the sentence is true but no longer useful. */} {emailVerification && prefs.email?.enabled !== true && (

    Confirming this address does not start email notifications on its own. To receive them you also need to tick “Email notifications” above.

    )} {emailVerification && (
    {/* KEYED ON THE FLAG, NEVER ON THE PRESENCE OF A DATE. * `verified` and `verifiedAt` are ONE fact: an address that * is not verified has no verification date, and a stale * timestamp beside a false flag must render as neither. */} {emailVerification.verified ? (

    Email address confirmed {/* The shared formatter, not toLocaleDateString. * That rendered 8/5/2026, which is ambiguous to * anybody who reads day-first and is not the * settled house date - a spelled month in US * order. The deck draws "Aug 5, 2026" and every * other dated row in the portal already uses * this function, so a second way of writing a * date is what was actually wrong here. */} {emailVerification.verifiedAt && ` on ${formatDate(emailVerification.verifiedAt)}`}

    ) : (

    We haven’t confirmed this address yet.

    {!emailVerifyPending ? ( ) : (
    Code sent to {emailVerifyPending.maskedAddress} { setEmailVerifyCode(e.target.value); setEmailVerifyError(null); }} disabled={emailVerifying || emailControlsBusy} className="w-32" aria-label="Code emailed to you" /> {/* THE RESEND MUST BE ON SCREEN WHILE A CODE IS * OUTSTANDING. After the attempt cap the server's own * copy — rendered verbatim below — says "Request a new * one"; the SMS side shipped without such a control * and recovery meant navigating away and back, with * the whole resend subsystem unreachable. Email needs * it even more: mail is slow and lands in spam. Same * endpoint as the initial request — the server decides * resend-vs-replace. */}
    )} {emailVerifyError && (

    {emailVerifyError}

    )}
    )}
    )}
    )} {c.key === 'sms' && patient && (

    Text reminders go to this number. You’ll be asked to confirm it before we start texting.

    {isAddingNew && ( { setSmsNewNumber(e.target.value); setSmsError(null); }} disabled={carrierBlocked || smsSaving || savingChannel !== null || smsVerifying || emailSaving || emailVerifying} className="w-40" /> )} {/* * NOT OFFERED WHILE THE CARRIER BLOCK IS STAMPED — and, like the * toggle above, not rendered rather than merely disabled. * * This button used to render UNCONDITIONALLY, six lines under copy * telling the patient the block cannot be cleared here. It could: * `saveSmsNumber` PUTs the whole Patient, and handlers/Patient/put.ts * treats a different, normalisable incoming number as a "proven move" * and RELEASES the stamp. The whole proof was ten digits the patient * typed — no ownership, no verification, no telecom check. Text STOP, * be told to text START, type any other number, Save: stamp gone, * toggle back, re-enable, switch back to the real blocked number. The * one signal telling patient, staff and UI WHY sms is dead, laundered * away. * * The server half is the primary defence (put.ts now demands the * released number already be on the patient's stored telecom as * `use: 'mobile'`). This half is what stops the page contradicting * its own instruction. */} {!carrierBlocked && ( )}
    {smsError &&

    {smsError}

    } {smsStale && (
    {RELOAD_DISCARDS_MESSAGE}{' '}
    )} {/* * Shown whether or not the number is verified — AFTER verifying * is exactly when a patient would otherwise sit waiting for * texts that are never coming. */} {!smsDeliveryConsented && (

    Verifying this number will not start text messages on its own. To get reminders by text you also need to turn on “Text (SMS) messages” above and “Appointment reminders” below.

    )} {smsVerification && (
    {smsVerification.verified ? (

    Verified {smsVerification.verifiedAt && ` on ${formatDate(smsVerification.verifiedAt)}`}

    ) : (

    This number hasn’t been verified yet. Until it is, reminders will be sent by email instead of text.

    {!smsVerifyPending ? ( ) : (
    Code sent to {smsVerifyPending.maskedNumber} { setSmsVerifyCode(e.target.value); setSmsVerifyError(null); }} disabled={smsVerifying || smsControlsBusy} className="w-32" aria-label="Verification code" /> {/* * THE ONLY CONTROL THAT CAN ISSUE A CODE USED * TO DISAPPEAR THE MOMENT ONE WAS OUTSTANDING. * After MAX_ATTEMPTS the server's own copy — * rendered verbatim above — tells the patient to * "Request a new one", and there was no such * control on screen: recovery meant navigating * away and back, and the entire resend subsystem * (registerResend / MAX_RESENDS / the rolling * window) was unreachable from this page. * * Same endpoint as the initial request — the * server decides resend-vs-replace, and its 429 * maps through describeSmsRequestError. */}
    )} {smsVerifyError && (

    {smsVerifyError}

    )}
    )}
    )}
    )}
  • ); })}
{dialogMeta && ( void save(dialogMeta.key, true)} onClose={() => { preferenceDraft.current = false; setDialogChannel(null); }} /> )}
); } /** * - WHICH RECORD IS THIS SCREEN FOR? Resolved here, before anything mounts. * * `patientState` is the app-wide SUBJECT: `PatientService` re-points it to `actingAs.subjectPatientId` * while a proxy is switched in, and to the caller's own Patient otherwise. landed the same * substitution on *"My details"*; this page needed it more, because it saves. * * IT FAILS CLOSED WHILE ACTING-AS, and a `patientState ?? currentUser.fhir` fallback is explicitly * forbidden: it would render the guardian's values under the dependent's banner for exactly as long as the * subject took to load - this defect again, briefly, on a screen with a Save button. * * AND THE `key` IS LOAD-BEARING. A guardian with two dependents can switch from one to the other * without leaving this route; without a key the inner component keeps the FIRST subject's seeded state * while displaying the second one's banner - the same defect, one dependent along. * * The `currentUser.fhir` fallback survives ONLY on the non-acting path, where it genuinely IS the * subject - and that path is what the two controls in this page's tests protect. */ export function PatientSettingsPage() { const { currentUser, actingAs } = useAuthServiceContext(); const { patientState } = usePatientServiceContext(); // lucide, not `@wordpress/components`' Spinner - ZERO new WordPress. `ProfilePage` still uses the // WP one because it predates that rule; a new call site does not get to add one. const loading = (
); if (actingAs) { if (patientState?.id !== actingAs.subjectPatientId) { return loading; } // ANY HELPER, NOT A TIER: the server refuses a preference change from assist-only, from scheduling & // forms and from full proxy alike. const readOnlyReason = describeProxyCannotChangePreferences(actingAs.subjectName); return ; } const own = (patientState ?? currentUser?.fhir) as Patient | undefined; if (own?.resourceType !== 'Patient') { return null; } return ; } export default PatientSettingsPage;