// Related-person "act-as" account switcher, client Task 3 - switch ORCHESTRATION built // on top of Task 1's act-as state (services/AuthService.tsx: actingAs/actingAsRef/setActingAs) and // X-Acting-As transport (services/HttpService.tsx: fetchWithAuth). Task 1 only wired the plumbing; // this hook owns the actual enter/exit/revalidate flow. The switch-INTO page and the header banner // are later tasks - this hook and its confirm dialog (components/ActAs/ConfirmActAsDialog.tsx) are // the only consumers wired up so far. // // Fail-closed is the core guarantee: enter() acts optimistically (setActingAs FIRST, so the header's // X-Acting-As starts flowing and any consumer reacting to `actingAs` switches immediately) but is // validated against a live GET on the subject Patient - a stale/revoked RelatedPerson grant reverts // to self rather than leaving the UI stuck mid-proxy. exit() is the mirror: it drops state FIRST // (stops the header immediately) then best-effort tells the server the session ended; a failed exit // call must never surface as a UI error since the server's session TTL is the backstop (see // aws/sam/services/api/src/utils/proxyAccessSessions.ts on the infra side). import { useCallback } from 'react'; import type { Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useHttpService, HttpError } from '../services/HttpService.tsx'; import { useAuthServiceContext } from '../services/AuthService.tsx'; import type { ActingAsTarget } from '../services/AuthService.tsx'; import { withinDeadline } from '../utils/withinDeadline'; const FHIR_API_URL = environment.fhirIdentityApi; // POST /R4/proxy-access (the exit/session-end signal) is registered on the guardianship gateway // (aws/sam/services/api/templates/fhir/05-guardianship.yaml - its own custom domain, derived from // FhirClinicalApiUrl as `fhir-guardianship`), same as GET /my-proxies (useMyProxies.tsx). const PROXY_ACCESS_API_URL = environment.fhirGuardianshipApi; // How long a re-check of a session already in progress may take, token and device proof included. Past it the // check is 'unknown': the session stands, and the next check can start. export const REVALIDATE_DEADLINE_MS = 10_000; /** * WHAT A CHECK OF THE GRANT CAN ACTUALLY REPORT. Three answers, not two. * * It used to be a boolean, and "cannot confirm" was folded into "confirmed no access" as a * fail-closed measure. That reasoning inverts here, and it cost a data-integrity defect: reverting * to self SILENTLY is not the safe direction. The guardian goes on reading what they believe is * their child's chart, and writes go to their own record under their own identity with the audit * trail agreeing. The confirm, the recorded switch and the banner all exist to prevent exactly * that, and a quiet revert defeats all three at once. * * 'unknown' is not a softer 'revoked'. It means the question was never answered - and the commonest * cause is the most ordinary one: `getValidToken()` throwing while a refresh is in flight, which * this layer reports as a plain Error rather than a 403. Right after `enter()` reloads the page, * the window regains focus and the banner revalidates immediately, so an auth layer that is a * moment from ready reads exactly like a grant that was taken away. */ type GrantCheck = 'ok' | 'revoked' | 'unknown'; export const ACTING_AS_ENDED_MESSAGE = 'You are no longer viewing that account. Anything you do now applies to your own record.'; export const useActAs = () => { const { actingAs, actingAsRef, actingAsSessionRef, wpOrganization, setActingAs, isActingAsEntering, setIsActingAsEntering, actingAsEnded, acknowledgeActingAsEnded, actingAsEntryNotice, noteActingAsEntryFailed, acknowledgeActingAsEntryNotice, } = useAuthServiceContext(); const { get, post } = useHttpService(); // Re-fetch the subject Patient to confirm the acting-as grant is still honored. // // ONLY A DEFINITIVE REFUSAL ENDS THE SESSION. A 403 is the server saying no. A network error, a // 5xx, or an auth layer that is not ready yet are all the check failing to happen, and ending a // guardian's session because a request timed out is a worse outcome than leaving it standing // for the seconds until the next check - the grant is enforced on the SERVER for every read and // write regardless, so nothing is reachable on a stale banner that would not be reachable // anyway. const validateSubject = useCallback(async ( subjectPatientId: string, signal?: AbortSignal, deadlineMs?: number, ): Promise => { try { await withinDeadline(get(`${FHIR_API_URL}/Patient/${subjectPatientId}`, { signal }), deadlineMs, 'act-as check'); return 'ok'; } catch (err) { if (err instanceof HttpError && err.status === 403) { return 'revoked'; } console.error('act-as subject validation could not be completed:', err); return 'unknown'; } }, [get]); const enter = useCallback(async (target: ActingAsTarget): Promise => { // Entering/validating (A3b): flip true BEFORE the optimistic setActingAs below so any // consumer gating on isEntering (ActingAsBanner) never renders mid-validate - closes the // window where a bad target would otherwise flash the "Viewing X" banner just before the // 403 reverts it. setIsActingAsEntering(true); // Set first so the X-Acting-As header is live for the validation GET itself - the check // must prove the acting-as view actually works, not just that the subject Patient exists. setActingAs(target); const attempt = actingAsSessionRef.current; // ENTERING STAYS FAIL-CLOSED, and the asymmetry with revalidate below is the point. // Nothing has been shown yet, the person is standing here waiting for an answer, and a // refusal is visible to them as the switch not happening. Neither of those is true of a // check that runs by itself on a still page seconds later. const entryCheck = await validateSubject(target.subjectPatientId); // A later entry, exit, sign-out or change of viewer owns the context now; this answer changes nothing. if (actingAsSessionRef.current !== attempt) return false; if (entryCheck !== 'ok') { // ANNOUNCED: nothing ended here, because nothing had started. The notice says why the switch did not // happen, since entering remounted the dialog that asked. setActingAs(null, { announced: true }); noteActingAsEntryFailed(target, entryCheck === 'revoked' ? 'refused' : 'unknown'); setIsActingAsEntering(false); return false; } setIsActingAsEntering(false); // Option B: reload rather than trying to live-swap ~40 resource caches held by // providers above the router. setActingAs already persisted the target to localStorage // (AuthService.tsx: writePersistedActingAs), so the fresh load re-hydrates actingAs on the // very first render and PatientService's act-as effect re-fetches + displays the subject - // same precedent as logout()'s window.location.reload() (AuthService.tsx:357). window.location.reload(); return true; }, [actingAsSessionRef, noteActingAsEntryFailed, setActingAs, setIsActingAsEntering, validateSubject]); const exit = useCallback(async (): Promise => { const captured = actingAs; // Clear state FIRST - stops X-Acting-As on every subsequent request immediately, before the // (best-effort, possibly slow/failing) network call below even starts. // // ANNOUNCED: they pressed Exit. Telling somebody what they have just done themselves is // noise, and noise is how a notice that matters gets dismissed without being read. setActingAs(null, { announced: true }); if (!captured) return; try { // bypassActingAs: this must go out as a plain-Patient call - actingAsRef is already // cleared above, but the flag is defensive/explicit regardless of ordering. await post(`${PROXY_ACCESS_API_URL}/proxy-access`, { actingAs: `RelatedPerson/${captured.relatedPersonId}`, }, { bypassActingAs: true }); } catch (err) { // Best-effort only. The server's session TTL is the backstop, so a failed exit // notification must never throw at the UI. console.error('proxy-access exit notification failed (non-fatal, TTL is the backstop):', err); } // Reload AFTER the (awaited) POST attempt so the exit notification actually gets to fire // before navigation tears down the page; loads fresh as self, mirroring enter()'s reload. window.location.reload(); }, [actingAs, setActingAs, post]); /** * Re-confirm a session already in progress. * * RETURNS WHO IT ENDED, so the caller can SAY SO. A grant that ends must return the guardian to * their own context loudly and never by a banner ceasing to exist - an absence is the one thing * a reader cannot tell apart from a screen that has not finished rendering, and this one used to * end exactly that way. * * 'unknown' changes nothing at all. The session stands until a check actually answers. A check that has not * answered by its deadline is 'unknown', so it can never hold the next one off. */ const revalidate = useCallback(async (signal?: AbortSignal): Promise<{ ended: string } | undefined> => { if (!actingAs) return undefined; const asked = actingAs; // The session asked about, by generation rather than by ids: leaving and entering the same person // again, or another viewer or practice, is a different session that an old answer must not end. const session = actingAsSessionRef.current; const practice = wpOrganization.current?.id; const check = await validateSubject(asked.subjectPatientId, signal, REVALIDATE_DEADLINE_MS); if (check !== 'revoked' || signal?.aborted) return undefined; if (actingAsSessionRef.current !== session || wpOrganization.current?.id !== practice || actingAsRef.current !== asked) { return undefined; } const ended = actingAs.subjectName || 'that account'; setActingAs(null); return { ended }; }, [actingAs, actingAsRef, actingAsSessionRef, wpOrganization, setActingAs, validateSubject]); // The notice travels with the rest of the act-as API rather than being fetched from the auth // context separately: it IS part of what acting-as means, and a consumer that has to know // about two providers to render one banner is a consumer that will forget the second. return { actingAs, enter, exit, revalidate, isEntering: isActingAsEntering, actingAsEnded, acknowledgeActingAsEnded, actingAsEntryNotice, acknowledgeActingAsEntryNotice, }; };