// Related-person "act-as" account switcher, client Task 2b - the "who can I switch // into" list. This is the mirror image of useFhirRelatedPersons: that hook lists the RelatedPersons // who point AT the current patient (people who can act for ME); this hook lists the RelatedPerson // grants the current LOGGED-IN USER holds over OTHER patients (people I can act AS). Each returned // proxy is directly usable as (or trivially mappable to) an ActingAsTarget for useActAs().enter() // (services/AuthService.tsx) / T3's confirm dialog. // // GET /my-proxies is registered on the guardianship gateway (aws/sam/services/api/templates/fhir/ // 05-guardianship.yaml), same as POST /proxy-access that useActAs.exit() calls. // // bypassActingAs: true - this must always be MY list, even while I'm currently switched into // someone else, so the request must never carry X-Acting-As (mirrors useActAs.exit() and the // "my proxies" list comment in services/HttpService.tsx's fetchWithAuth). import { useCallback, useEffect, useRef, useState } from 'react'; import type { CodeableConcept } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService } from '../services/HttpService.tsx'; import { useAuthServiceContext } from '../services/AuthService.tsx'; import { awaitedLinkIds, cancelAcceptedLink, currentAcceptedLink, nextProxyReadSeq, onAcceptedLink, onAcceptedLinkReadFinished, provideProxyReader, recheckPresentLink, retryAcceptedLink, type AcceptedLinkSnapshot, } from './acceptedLinkRefresh'; import { describeFetchFailure } from '../utils/fetchFailureMessage'; import { withinDeadline } from '../utils/withinDeadline'; import { viewerEpoch } from '../utils/writeThroughHold'; import type { AuthorityTier } from '../services/relatedPersonAuthority'; export interface MyProxy { relatedPersonId: string; subjectPatientId: string; subjectName: string; relationship: CodeableConcept[]; // (financial disclosure) - the caller's per-subject financial grant, resolved server-side // from the same accessor the enforcing handlers use. Optional on the type because a response from a // pre-028 deployment omits them, and `undefined` must read as DENIED, never as "allowed". // Consume through useCurrentBillingAccess, which applies that default. allowBillingView?: boolean; allowBillingPay?: boolean; // The patient's "Message the practice for me" on this caller's grant. Optional and read as DENIED // when absent, like the billing flags: it decides whether to offer writing in the patient's name. allowMessaging?: boolean; // Minor-Child Slice 2 follow-up - does this subject ALREADY have their own Cognito login? Decided // server-side (utils/patientHasLogin.ts, shared with the activate handler's own guard) because // nothing else on this row carries an account signal and the client must not guess at an identity // question. Gates the "give them their own login" action, which is only valid for a login-less // dependent. Optional for the same reason as the 028 flags above - a pre-deploy response omits it - // but note the SAFE DEFAULT IS INVERTED here: `undefined` must read as "already has a login" (hide // the action), never as "offer it". Offering it wrongly is what caused the bug this field fixes. subjectHasLogin?: boolean; // - is this subject a minor? Gates the co-guardian invite, which is a minor-child // action and must not appear beside an adult spouse (the mistake `subjectHasLogin` above was added // to fix for the activate-login action). // // Derived server-side from birthDate at READ time, so it self-corrects the day a child turns 18 // rather than going stale. Optional for the same reason as the flags above - a pre-deploy server // omits it - and the safe default is again INVERTED: `undefined` must read as "not a minor" and hide // the action, never as "show it". subjectIsMinor?: boolean; // Attested guardianship of this minor, by the server's own guardian-only predicate. Absent when unconfirmed, // which offers no guardian-only action. callerIsGuardian?: boolean; // - is this 16-17 year old missing their own email or mobile? // // It exists because `Patient/dependent.ts` creates a child with NO telecom at all. Left alone, the // guardianship ends at 18 with no channel to notify the teen AND no contact for the self-claim to // verify against - one gap blocking two halves of the transition. Prompting the guardian from 16 stops // that state persisting, rather than working around it at 18. // // Derived server-side at read time (age + telecom), so it clears itself the moment the contact is // added and closes at 18 without a scheduled job. Optional and default-OFF like its siblings: a // pre-deploy server omits it, and `undefined` must read as "nothing to ask for" - a prompt shown in // error is noise a guardian cannot act on, and erodes trust in every other prompt beside it. subjectNeedsTakeoverContact?: boolean; /** * THE CARE TIER THIS CALLER HOLDS OVER THIS SUBJECT, and the only place the client can learn it. * * `GET /R4/related-person-authority` is patients-only and self-scoped: a helper acting for * somebody is refused it, the RelatedPerson they can read carries no tier, and the grant lives in * a Consent they may not read. So without this field a helper finds out what their tier forbids * by being refused - which is how an assist-only helper came to answer a whole questionnaire * before a 403 told them. * * Optional, and `undefined` reads as PERMITTED - the opposite direction from every flag above it, * deliberately. Those decide whether to show something and fail closed; this decides whether to * let somebody start work the server will refuse anyway, and locking out a helper who may act is * the worse error. See `mayCompleteFormsAsProxy`. */ careTier?: AuthorityTier; /** * True when the patient granted MORE than careTier and identity proofing is what holds it down. * It lets a screen say which, instead of implying the patient chose the smaller grant. */ tierReducedForProofing?: boolean; /** True when the server found no grant yet, as just after one is written; careTier is its fail-closed default. */ tierUnresolved?: boolean; } /** A quiet reread is over by then, well before the next is due. */ export const PROXY_REREAD_DEADLINE_MS = 10_000; interface MyProxiesResponse { proxies: MyProxy[]; /** False when the server could not settle every account; an older server sends none. */ complete?: boolean; } /** The list as the server answered it: `complete` false means an account missing from it may still be there. */ export interface MyProxiesSnapshot { proxies: MyProxy[]; complete: boolean } /** A list the server said is not whole: it cannot stand for the whole list, so a caller treats it as unread. */ export class ProxyListIncompleteError extends Error { constructor() { super('This list could not be read whole just now.'); this.name = 'ProxyListIncompleteError'; } } /** One read of the caller's OWN list, never as the person they are acting for, with whether it is whole. */ export const readMyProxiesSnapshot = async ( get: ReturnType['get'], signal?: AbortSignal, accountId?: string, admit?: () => void, ): Promise => { const awaiting = accountId ? awaitedLinkIds(accountId) : []; const query = awaiting.length ? `?awaiting=${awaiting.map(encodeURIComponent).join(',')}` : ''; const response = await get( `${environment.fhirGuardianshipApi}/my-proxies${query}`, { bypassActingAs: true, signal, ...(admit ? { admit } : {}) }, ); // A list is an array of accounts; whether it is whole is a boolean, or absent from an older server. if (!response || !Array.isArray(response.proxies) || (response.complete !== undefined && typeof response.complete !== 'boolean')) { throw new Error('The list came back in a shape it cannot be read in.'); } return { proxies: response.proxies, complete: response.complete !== false }; }; /** The whole list, for a caller that reads absence as an answer; a partial one is refused rather than handed out. */ export const readMyProxies = async ( get: ReturnType['get'], signal?: AbortSignal, accountId?: string, admit?: () => void, ): Promise => { const snapshot = await readMyProxiesSnapshot(get, signal, accountId, admit); if (!snapshot.complete) throw new ProxyListIncompleteError(); return snapshot.proxies; }; // Automatic reads in flight share one read: same session (the token source every consumer's `get` is built on), practice, // account stretch, viewer and `awaiting`. A joiner keeps the read's own sequence; none joins one older than a fresh read // of the account. The shared answer is bounded, and nothing is kept once it settles or its last owner leaves. type SharedRead = { seq: number; read: Promise; owners: number; drop: () => void }; const inFlight = new WeakMap>(); const freshReadSeq = new Map(); let accountStretch = { account: undefined as string | undefined, n: 0 }; const noteFreshRead = (accountId: string | undefined, seq: number) => { if (accountId) freshReadSeq.set(accountId, Math.max(freshReadSeq.get(accountId) ?? 0, seq)); }; // The accepted-link wait's reads, answered or not, are fresh reads too. onAcceptedLinkReadFinished(noteFreshRead); const automaticRead = ( get: ReturnType['get'], session: object | undefined, practiceId: string | undefined, accountId: string | undefined, ): SharedRead => { if (accountStretch.account !== accountId) accountStretch = { account: accountId, n: accountStretch.n + 1 }; const awaiting = accountId ? [...awaitedLinkIds(accountId)].sort().join(',') : ''; const key = [accountId ?? '', accountStretch.n, viewerEpoch(), practiceId ?? '', awaiting].join('|'); const owner = session ?? get; let held = inFlight.get(owner); if (!held) inFlight.set(owner, (held = new Map())); const existing = held.get(key); if (existing && existing.seq > (accountId ? freshReadSeq.get(accountId) ?? 0 : 0)) { existing.owners += 1; return existing; } const table = held; const shared: SharedRead = { seq: nextProxyReadSeq(), read: withinDeadline(readMyProxiesSnapshot(get, undefined, accountId), PROXY_REREAD_DEADLINE_MS, 'my-proxies read'), owners: 1, drop: () => { if (table.get(key) === shared) table.delete(key); }, }; table.set(key, shared); shared.read.then(shared.drop, shared.drop); return shared; }; /** One owner's hold on a shared read, let go once: the read is dropped when nobody waits on it any more. */ const holdOf = (shared: SharedRead) => { let held = true; return () => { if (!held) return; held = false; shared.owners -= 1; if (shared.owners <= 0) shared.drop(); }; }; // unavailable: the read itself failed, so standing is unknown; unconfirmed: the answer was not this caller's to use. export type ProxyConfirmation = { status: 'confirmed'; proxies: MyProxy[] } | { status: 'unavailable' } | { status: 'unconfirmed' }; interface UseMyProxiesReturn { proxies: MyProxy[]; /** * Whether the list shown is a whole answer for this account. Only then does a link missing from `proxies` * mean it is gone (see `proxyStanding`); before that, or from a partial answer, its absence means unknown. */ listAnswered: boolean; /** The newest read of the list failed: what it showed may no longer hold, so nothing guardian-only is offered. */ latestUnconfirmed: boolean; isLoading: boolean; error: string | undefined; refetch: (signal?: AbortSignal, deadlineMs?: number) => Promise; /** The open list reading itself again: no loading state, and a failed read keeps the list on screen. */ rereadQuietly: (signal?: AbortSignal) => Promise; /** * A fresh read for a write about to be made on the strength of this list. Confirmed only when it is the * newest read to have finished for this account: a read started later that has already answered, even with * a failure, retires it, so an older Forms answer can never authorize a write a newer read would not. */ confirmProxies: (signal?: AbortSignal, deadlineMs?: number) => Promise; /** Refetch until a subject known to have been created appears, or the attempts run out. */ refetchUntilPresent: (subjectPatientId: string, attempts?: number, delayMs?: number) => Promise; /** Refetch until a subject we KNOW was just given a login reads as having one. */ refetchUntilHasLogin: (subjectPatientId: string, attempts?: number, delayMs?: number) => Promise; /** A link this account just accepted that the list may not show yet, and how waiting for it went. */ acceptedLink: AcceptedLinkSnapshot | null; retryAcceptedLink: () => Promise; } export const useMyProxies = (): UseMyProxiesReturn => { const { currentUser, actingAs, getValidToken, wpOrganization } = useAuthServiceContext(); const { get } = useHttpService(); const accountId = currentUser?.fhir?.id; const [proxies, setProxies] = useState([]); const [latestUnconfirmed, setLatestUnconfirmed] = useState(false); // The account whose list has answered at least once. const [answeredFor, setAnsweredFor] = useState(); // Whether the list on screen came whole: a partial one decides no account's absence. const [shownWhole, setShownWhole] = useState(true); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const ownAccepted = (snapshot: AcceptedLinkSnapshot | null) => snapshot && accountId && snapshot.accountId === accountId ? snapshot : null; const [acceptedLink, setAcceptedLink] = useState(() => ownAccepted(currentAcceptedLink())); // The sequence of the list on screen: an older read never replaces a newer one. const shownSeq = useRef(0); // The newest read to have FINISHED, answered or failed: what a write confirmation must not be older than. const decidedSeq = useRef(0); const accountRef = useRef(accountId); // The read the loading state belongs to. const loadingRead = useRef<{ gen: number; seq: number } | null>(null); // Moves on every change of account, so an old read is not taken for the same account come back (A, B, A). const accountGen = useRef({ id: accountId, gen: 0 }); if (accountGen.current.id !== accountId) accountGen.current = { id: accountId, gen: accountGen.current.gen + 1 }; accountRef.current = accountId; // An answer for a consumer that has left is published nowhere, and its holds on shared reads are let go. const mounted = useRef(true); const holds = useRef(new Set<() => void>()); useEffect(() => { mounted.current = true; const held = holds.current; return () => { mounted.current = false; held.forEach((leave) => leave()); held.clear(); }; }, []); // Only fetch once the user is logged in as themselves, a normal Patient (Phase 1 scope) - // gating here (rather than trusting callers) keeps this hook safe to mount anywhere, including // pre-auth, without tripping the app-provider auth-gate reload loop: an authed fetch with no // session drives getValidToken -> refresh -> logout -> window.location.reload() -> // remount -> refetch, forever (see reference-app-provider-auth-gate memory; mirrors how // useFhirRelatedPersons gates on patientState). const isSelfPatient = currentUser?.fhir?.resourceType === 'Patient'; /** * Returns what it stored, so a caller can decide on the RESPONSE rather than on React state. * * `refetchUntilPresent` below has to test each attempt's result before deciding whether to try * again, and `proxies` is a `useState` value that does not update until the next render - reading * it in the loop would test the answer from BEFORE the fetch, every time, and the loop would * always run to its last attempt while looking like it was checking something. */ // `quiet`: a reread of a list already on screen. It shows no loading state, and a failure keeps the list and // says nothing - the list on screen is still the last thing known, and the next reread asks again. const fetchProxiesReturning = useCallback(async (signal?: AbortSignal, deadlineMs?: number, quiet = false, automatic = false): Promise => { if (!isSelfPatient) { return []; } // An automatic read may share one in flight; any other is its own, and is noted as fresh for the account. const shared = automatic && !signal ? automaticRead(get, getValidToken, wpOrganization?.current?.id ?? undefined, accountRef.current) : undefined; const leave = shared ? holdOf(shared) : undefined; if (leave) holds.current.add(leave); const seq = shared?.seq ?? nextProxyReadSeq(); if (!shared) noteFreshRead(accountRef.current, seq); const forAccount = accountGen.current.gen; // Loading and the error are the current account's newest read's to change: an older read, or one of an // account no longer on screen (A, B; or A, B, A), ends no loading and puts no failure on it. const current = () => forAccount === accountGen.current.gen; if (!quiet) { loadingRead.current = { gen: forAccount, seq }; setIsLoading(true); setError(undefined); } try { const snapshot = await withinDeadline(shared?.read ?? readMyProxiesSnapshot(get, signal, accountRef.current), deadlineMs, 'my-proxies read'); const next = snapshot.proxies; if (!mounted.current) return next; if (forAccount === accountGen.current.gen) { // An older answer never stands over a newer read that has finished, answered or failed: a newer // failure keeps the last known list on screen, it does not let an older (broader) one back in. const newerFinished = seq < decidedSeq.current; decidedSeq.current = Math.max(decidedSeq.current, seq); if (!newerFinished && seq > shownSeq.current) { shownSeq.current = seq; // Answered only with the list this answer shows: one never shown decides no account's absence. setAnsweredFor(accountRef.current); // A partial answer shows the accounts it has, and says the list is unconfirmed: an account missing // from it is unknown, never gone, so nothing is concluded from its absence. setProxies(next); setShownWhole(snapshot.complete); setLatestUnconfirmed(!snapshot.complete); setError(undefined); if (snapshot.complete) recheckPresentLink(accountRef.current, next); } } return next; } catch (err) { const newest = current() && seq >= decidedSeq.current; if (current()) decidedSeq.current = Math.max(decidedSeq.current, seq); console.error('Failed to load my-proxies list:', err); if (newest) setLatestUnconfirmed(true); if (!quiet && newest) setError(describeFetchFailure(err, 'Failed to load switch-into list')); return []; } finally { if (leave) { leave(); holds.current.delete(leave); } if (!quiet && current() && loadingRead.current?.gen === forAccount && loadingRead.current.seq === seq) { loadingRead.current = null; setIsLoading(false); } } }, [isSelfPatient, get, accountId, getValidToken, wpOrganization]); /** The open list reading itself again: bounded, and quiet (see `fetchProxiesReturning`). */ const rereadQuietly = useCallback(async (signal?: AbortSignal) => { await fetchProxiesReturning(signal, PROXY_REREAD_DEADLINE_MS, true); }, [fetchProxiesReturning]); const fetchProxies = useCallback(async (signal?: AbortSignal, deadlineMs?: number) => { await fetchProxiesReturning(signal, deadlineMs); }, [fetchProxiesReturning]); const confirmProxies = useCallback(async (signal?: AbortSignal, deadlineMs = PROXY_REREAD_DEADLINE_MS): Promise => { if (!isSelfPatient) return { status: 'unconfirmed' }; const seq = nextProxyReadSeq(); noteFreshRead(accountRef.current, seq); const forAccount = accountGen.current.gen; try { const snapshot = await withinDeadline(readMyProxiesSnapshot(get, signal, accountRef.current), deadlineMs, 'my-proxies read'); const next = snapshot.proxies; // An answer the asking operation has given up decides nothing, even one that arrived in full. if (signal?.aborted || forAccount !== accountGen.current.gen || seq < decidedSeq.current) return { status: 'unconfirmed' }; decidedSeq.current = seq; // A confirmation is a read like any other: the newest one decides whether standing is known. A partial // one shows what it has and confirms nothing: no account's absence, and no standing, is decided by it. setLatestUnconfirmed(!snapshot.complete); if (seq > shownSeq.current) { shownSeq.current = seq; setAnsweredFor(accountRef.current); setProxies(next); setShownWhole(snapshot.complete); } return snapshot.complete ? { status: 'confirmed', proxies: next } : { status: 'unavailable' }; } catch (err) { const ours = forAccount === accountGen.current.gen && !signal?.aborted; if (ours && seq >= decidedSeq.current) { decidedSeq.current = seq; setLatestUnconfirmed(true); } console.error('Failed to confirm my-proxies list:', err); return ours ? { status: 'unavailable' } : { status: 'unconfirmed' }; } }, [isSelfPatient, get]); // Another account: nothing of the last one's loading or failure is this one's. Before the read below starts. useEffect(() => { loadingRead.current = null; setIsLoading(false); setError(undefined); }, [accountId]); useEffect(() => { if (!isSelfPatient) { return; } // While an accepted link is being waited for, that loop's reads are this list's reads too. if (ownAccepted(currentAcceptedLink())?.status === 'updating') { return; } void fetchProxiesReturning(undefined, undefined, false, true); // eslint-disable-next-line react-hooks/exhaustive-deps -- ownAccepted reads accountId, already here }, [isSelfPatient, fetchProxiesReturning, accountId]); // Every mounted list hears the accepted-link loop, so Family updates although the Accept was elsewhere. useEffect(() => onAcceptedLink((snapshot) => { const own = snapshot && snapshot.accountId === accountRef.current ? snapshot : null; setAcceptedLink(own); // The accepted-link loop's reads answer to the same rule: never over a newer read that has finished. if (own?.proxies && own.seq && own.seq > shownSeq.current && own.seq >= decidedSeq.current) { // A newer finished read like any other: an older confirmation answering after it confirms nothing. decidedSeq.current = own.seq; shownSeq.current = own.seq; setProxies(own.proxies); setShownWhole(true); setError(undefined); setAnsweredFor(own.accountId); } }), []); // The accepted-link wait's reads finish too, answered or not: none older than them is shown after. useEffect(() => onAcceptedLinkReadFinished((forAccount, seq) => { if (forAccount === accountRef.current) decidedSeq.current = Math.max(decidedSeq.current, seq); }), []); // Lends this account's own list reader to the accepted-link wait. useEffect(() => { if (!isSelfPatient || !accountId) return undefined; return provideProxyReader({ accountId, read: (signal, admit) => readMyProxies(get, signal, accountId, admit) }); }, [isSelfPatient, accountId, get]); // Another account, or acting for somebody: the wait belongs to nobody on this screen any more. useEffect(() => { const running = currentAcceptedLink(); if (running && (actingAs || running.accountId !== accountId)) cancelAcceptedLink(); }, [actingAs, accountId]); /** * Refetch until a subject we KNOW was created shows up, or the attempts run out. * * HEALTHLAKE IS READ-AFTER-WRITE EVENTUAL, AND THAT INVITES A DUPLICATE CHILD. Adding a child * returns 201 with the new patient id, and the immediate refetch behind it can still come back * without them - measured on staging 2026-08-29, where the child was absent right after the create * and present on a fresh session minutes later. So the guardian is told "they were added", sees a * list that does not contain them, and adds them again. Duplicate child records are the specific * thing the identity-matching work exists to prevent, and a race that invites the retry is the same * defect from the other end. * * Deliberately NOT an optimistic insert. This list is what the server says the caller may act * for; a row this hook invented would be a client claim about an authority grant, and the one time * it was wrong it would be wrong in the permissive direction. Waiting shows the real answer late; * inventing shows a possibly-false answer early. * * It settles either way: the last attempt's result is kept whether or not the subject appeared, so * a genuinely missing child still surfaces as missing rather than spinning. */ const refetchUntil = useCallback( async (settled: (proxies: MyProxy[]) => boolean, attempts: number, delayMs: number): Promise => { for (let attempt = 0; attempt < attempts; attempt += 1) { if (attempt > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (settled(await fetchProxiesReturning())) { return; } } }, [fetchProxiesReturning], ); const refetchUntilPresent = useCallback( (subjectPatientId: string, attempts = 4, delayMs = 400): Promise => refetchUntil( (found) => found.some((proxy) => proxy.subjectPatientId === subjectPatientId), attempts, delayMs, ), [refetchUntil], ); /** * The same race as above, on giving a child their own login: the write returns 200 and an * immediate refetch can still read `subjectHasLogin: false`, so the guardian reopens the child * and is offered the action again. The activate route has a create-then-mark window, and a * second press is how you find it. * * Compared against `true` explicitly, like the action that opens the dialog: an absent flag * from an older server must leave the loop waiting, not settle it. */ const refetchUntilHasLogin = useCallback( (subjectPatientId: string, attempts = 4, delayMs = 400): Promise => refetchUntil( (found) => found.some( (proxy) => proxy.subjectPatientId === subjectPatientId && proxy.subjectHasLogin === true, ), attempts, delayMs, ), [refetchUntil], ); return { proxies, listAnswered: !!accountId && answeredFor === accountId && shownWhole, latestUnconfirmed, isLoading, error, refetch: fetchProxies, rereadQuietly, confirmProxies, refetchUntilPresent, refetchUntilHasLogin, acceptedLink, retryAcceptedLink, }; };