import { useEffect, useRef, useState, type ReactNode } from "react"; import clsx from "clsx"; import { Loader2, ShieldAlert } from "lucide-react"; import { RelatedPerson } from "fhir/r4"; import { toast } from "sonner"; import { Switch } from "../../components/ui/switch"; import { useRelatedPersonAuthorityService } from "../../services/RelatedPersonAuthorityService.tsx"; import type { AuthorityIntent, AuthorityTier } from "../../services/relatedPersonAuthority"; import { authoritySaveOutcome, isCurrentGrant, isEndedGrant, isSettledGrant, newAuthorityIntent } from "../../services/relatedPersonAuthority"; import { AUTHORITY_SAVE_DEADLINE_MS, isOnItsWay, onAuthoritySaveChange, recordAuthoritySave, unconfirmedFor, type UnconfirmedAuthoritySave, } from "../../services/unconfirmedAuthoritySaves"; import { onViewerChange, viewerEpoch } from "../../utils/writeThroughHold"; import { useLifetime } from "../../hooks/useLifetime"; import { withinDeadline } from "../../utils/withinDeadline"; import { buildPendingProofingBadgeLabel, DEFAULT_AUTHORITY_STATE, effectiveAllowMessaging, grantToState, messagingAfterTierChange, shouldShowPendingProofingBadge, } from "./careAccess.helpers"; import type { CareAccessState } from "./careAccess.helpers"; import { normalizeBillingGrant } from "../../services/relatedPersonAuthority"; import { useRelatedPersonIdentity } from "../../hooks/useRelatedPersonIdentity.tsx"; import { formatFhirHumanName } from "../../utils/fhirHumanName.tsx"; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { TierChoices } from "./TierChoices"; import OwnLinkAccess from "./OwnLinkAccess"; import type { ActingAsTarget } from "../../services/AuthService.tsx"; import { BILLING_DEPENDENCY_NOTE, CHANGE_TIER_DESCRIPTIONS } from "./tierWords"; import "./careAccess.css"; interface CareAccessProps { relatedPerson: RelatedPerson; /** Called with what was saved, so the list can show the new tier. */ onSaved?: (state: CareAccessState) => void; /** False where a person header above already names them. */ showHead?: boolean; /** Drawn at the left of the Save row, such as ending their access. */ secondaryAction?: ReactNode; /** What an invite just wrote for this person; shown until the grants read can see it. */ written?: CareAccessState; /** The person granting access is under 18, so a full proxy needs no verification. */ subjectIsMinor?: boolean; /** The link the signed-in person is acting through, if any. */ actingAs?: ActingAsTarget | null; } // How long one read of the grant may take, whole; past it the card says it could not load, and offers a retry. export const GRANT_READ_DEADLINE_MS = 10_000; // How long the card waits before reading a grant it could not show again, each time. export const AUTO_REREAD_DELAYS_MS = [3_000, 6_000, 12_000, 24_000, 45_000]; const sameState = (a: CareAccessState, b: CareAccessState) => a.tier === b.tier && a.allowMessaging === b.allowMessaging && a.allowBillingView === b.allowBillingView && a.allowBillingPay === b.allowBillingPay; // Change what someone can do: changes are staged and written once, on Save. Closing the sheet discards them. // A helper switched in through this very link reads their own authority; the grants read belongs to the // account owner and refuses them. const CareAccess = ({ actingAs, ...props }: CareAccessProps) => actingAs && actingAs.relatedPersonId === props.relatedPerson.id ? ( ) : ( ); const GrantEditor = ({ relatedPerson, onSaved, showHead = true, secondaryAction, written, subjectIsMinor = false }: Omit) => { const { getAuthorityForRelatedPerson, putAuthority } = useRelatedPersonAuthorityService(); const lifetime = useLifetime(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(DEFAULT_AUTHORITY_STATE); const [draftState, setDraft] = useState(DEFAULT_AUTHORITY_STATE); // Which person the grant on screen was read for. Until it is this one, nothing of another person's // grant is shown and nothing can be changed or saved. const [loadedFor, setLoadedFor] = useState(null); // And which opening read it: another viewer, practice or acted-as link on the same person is a new opening. const [loadedAt, setLoadedAt] = useState(null); const [saveError, setSaveError] = useState(null); // An unread grant is not the defaults on screen; saving them would replace it, so nothing is editable. const [loadFailed, setLoadFailed] = useState(false); // WHY THE LAST READ COULD NOT SHOW THE GRANT, kept until a read does: a read that ANSWERED but cannot show the // link yet (an invitation the list has not caught up with) is not a failure, and is not said as one. Kept // through the reread that follows, so the card does not blank and come back while it reads again. const [unreadable, setUnreadable] = useState<"failed" | "not-ready" | null>(null); // A guardianship the server says has ended: permanent, so it is said as such and never read again by itself. const [ended, setEnded] = useState(false); const [loadAttempt, setLoadAttempt] = useState(0); // The decision generation the grant on screen was read at; a change is based on it. A link with no grant is at 0. const [generation, setGeneration] = useState(0); // A change this viewer made for this person that the server never confirmed: offered to finish, as itself. const [unfinished, setUnfinished] = useState(); // A decision the server holds pending, at this generation, that nothing here can finish: only a fresh choice, // made deliberately and based on it, replaces it. const [pendingAt, setPendingAt] = useState(); // The person chose to replace that pending decision: Save sends what they choose, even unchanged from the floor. const [choosingAgain, setChoosingAgain] = useState(false); // The change being saved: the same one across retries of a save that was never confirmed, and a new one // for any other choice, person, generation or viewer. It never outlives this opening of the editor. const intentRef = useRef<{ key: string; intent: AuthorityIntent } | null>(null); const relatedPersonId = relatedPerson.id; // EACH OPENING IS ITS OWN: another person (the same one again included) or another viewer, practice or acted-as // link. A read or a save that answers for an earlier opening changes nothing here; the server's write stands. const openingRef = useRef(0); const openedFor = useRef(relatedPersonId); if (openedFor.current !== relatedPersonId) { openedFor.current = relatedPersonId; openingRef.current += 1; intentRef.current = null; } const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => { openingRef.current += 1; intentRef.current = null; setViewer(viewerEpoch()); }), []); const begin = () => { const opening = openingRef.current; const alive = lifetime(); return () => alive() && openingRef.current === opening; }; // Only the newest read decides what is shown, and the one in the air is aborted when another starts or the card goes. const readSeq = useRef(0); const readInFlight = useRef(null); // The opening the busy state and message on screen belong to. A save held for an earlier one never answers // here, so a new opening starts not busy and says nothing of it. const shownOpening = useRef(openingRef.current); const { name: proofingPersonName, proofingStatus, loading: identityLoading, } = useRelatedPersonIdentity(relatedPersonId); /** * Read the grant and show it: its rights and its generation together, or a lock when they are not known. * * After a save, the read is the next decision's base. A server that reports a generation is authoritative, so * what it says is shown, even if another session changed it in between; one that predates it may still list the * older grant, so what was just saved stays on screen and the generation is left alone. */ const read = async (afterSave = false) => { if (!relatedPersonId) return; const current = begin(); const seq = ++readSeq.current; const mine = () => current() && seq === readSeq.current; if (!afterSave) { setLoading(true); setLoadFailed(false); } if (shownOpening.current !== openingRef.current) { shownOpening.current = openingRef.current; setSaving(false); setSaveError(null); } readInFlight.current?.abort(); const controller = new AbortController(); readInFlight.current = controller; let answeredUnsettled = false; try { // Bounded, rights and generation together: a read that never answers is "could not load", never a guess. const grant = await withinDeadline(getAuthorityForRelatedPerson(relatedPersonId, controller.signal), GRANT_READ_DEADLINE_MS, "grant read") .finally(() => { controller.abort(); if (readInFlight.current === controller) readInFlight.current = null; }); if (!mine()) return; const kept = unconfirmedFor(relatedPersonId); setUnfinished(kept); setOnItsWay(isOnItsWay(kept?.intent.requestId)); setPendingAt(grant?.state === "pending" && typeof grant.generation === "number" ? grant.generation : undefined); // Ended is known and final: nothing is drawn as what they may do, and nothing is changed from it. if (grant && isEndedGrant(grant)) { setEnded(true); setGeneration(grant.generation ?? 0); setSaved(DEFAULT_AUTHORITY_STATE); setDraft(DEFAULT_AUTHORITY_STATE); setLoadFailed(false); setUnreadable(null); setChoosingAgain(false); setLoadedFor(relatedPersonId); setLoadedAt(openingRef.current); return; } // A decision still being written, unreadable, or a link this list cannot vouch for: neither shown nor // changed from. if (grant && !isSettledGrant(grant)) { answeredUnsettled = grant.state !== "pending"; throw new Error(`care access grant is ${grant.state}`); } if (afterSave && typeof grant?.generation !== "number") return; // A link with no grant is the assist-only floor. Only a server that predates the list's states says // nothing at all about a link, and only then does the card show what an invite just wrote. const state = grant ? isCurrentGrant(grant) ? grantToState(grant) : DEFAULT_AUTHORITY_STATE : written ?? DEFAULT_AUTHORITY_STATE; setEnded(false); setGeneration(grant?.generation ?? 0); setSaved(state); setDraft(state); setLoadFailed(false); setUnreadable(null); setChoosingAgain(false); setLoadedFor(relatedPersonId); setLoadedAt(openingRef.current); } catch (error: unknown) { if (!mine()) return; console.error("Failed to load care access grant:", error); setEnded(false); setLoadFailed(true); setUnreadable(answeredUnsettled ? "not-ready" : "failed"); setLoadedFor(relatedPersonId); setLoadedAt(openingRef.current); } finally { if (mine()) setLoading(false); } }; // A change for this person sent from anywhere on this page (the invite that just made them included): while it is // on its way the card says so, and when it comes back the card reads what it left in force. const [onItsWay, setOnItsWay] = useState(false); const lockedForReadRef = useRef(false); lockedForReadRef.current = loadFailed; useEffect(() => onAuthoritySaveChange((changed) => { if (changed !== relatedPersonId) return; const kept = unconfirmedFor(relatedPersonId); setUnfinished(kept); const travelling = isOnItsWay(kept?.intent.requestId); setOnItsWay(travelling); // Only a card that could not show the grant reads again: one that just saved reads its own answer back. if (!travelling && lockedForReadRef.current) setLoadAttempt((attempt) => attempt + 1); }), [relatedPersonId]); // A grant the list cannot show yet (a person just invited, whom the search has not caught up with; a decision // another page is still writing) is read again by itself, a few times and further apart, while the page is in // view. Try again stays offered; nothing is shown or allowed until a read says what is in force. // A reread that falls due while the page is hidden is held, and run once when it is seen again. const autoRereads = useRef(0); useEffect(() => { if (!loadFailed || onItsWay) return undefined; if (autoRereads.current >= AUTO_REREAD_DELAYS_MS.length) return undefined; const delay = AUTO_REREAD_DELAYS_MS[autoRereads.current]; let held = false; const rereadNow = () => { autoRereads.current += 1; setLoadAttempt((attempt) => attempt + 1); }; const onSeen = () => { if (!held || document.visibilityState === "hidden") return; held = false; rereadNow(); }; const timer = window.setTimeout(() => { if (document.visibilityState === "hidden") { held = true; return; } rereadNow(); }, delay); document.addEventListener("visibilitychange", onSeen); window.addEventListener("focus", onSeen); return () => { window.clearTimeout(timer); document.removeEventListener("visibilitychange", onSeen); window.removeEventListener("focus", onSeen); }; }, [loadFailed, onItsWay, loadAttempt]); useEffect(() => { autoRereads.current = 0; }, [relatedPersonId, viewer]); useEffect(() => { if (!relatedPersonId) { setLoading(false); return; } void read(); return () => { readSeq.current += 1; readInFlight.current?.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [relatedPersonId, loadAttempt, written, viewer]); if (!relatedPersonId) return null; // Read for this person in this opening: until the new opening's read answers, nothing an earlier one read is shown. const isThisPerson = loadedFor === relatedPersonId && loadedAt === openingRef.current; const draft = isThisPerson ? draftState : DEFAULT_AUTHORITY_STATE; // WHAT IS IN FORCE COULD NOT BE READ (not ready, not vouched for, still being written, or the read failed): no choice is // drawn - never the Assist-only defaults, never an earlier person's card, never what an invite wrote for a link the list // cannot vouch for. Nothing can be changed until the read lands. const unresolved = isThisPerson && (loadFailed || (loading && unreadable !== null)); const endedHere = isThisPerson && ended; const shown: CareAccessState | undefined = unresolved || endedHere ? undefined : draft; const relationshipLabel = relatedPerson.relationship?.[0]?.text ? relatedPerson.relationship[0].text.charAt(0).toUpperCase() + relatedPerson.relationship[0].text.slice(1) : ""; const fullName = formatFhirHumanName(proofingPersonName, { emptyText: "" }); const firstName = proofingPersonName?.[0]?.given?.[0]?.trim() || ""; const proofingBadgeName = fullName || relationshipLabel || "this person"; const showProofingBadge = !identityLoading && Boolean(shown) && shouldShowPendingProofingBadge(shown!.tier, proofingStatus, subjectIsMinor); const subline = [ relationshipLabel, !identityLoading && proofingStatus === "verified" ? "identity verified" : "", ].filter(Boolean).join(" · "); const locked = loading || saving || loadFailed || endedHere || !isThisPerson; const dirty = !sameState(draft, saved) || choosingAgain; // A pending decision nothing here can finish (made in another browser, or kept past its time): what they may do is // chosen again, from the floor, as a new decision based on the pending one. Nothing earlier is resent or rebased. const chooseAgain = () => { if (pendingAt === undefined) return; intentRef.current = null; setGeneration(pendingAt); setSaved(DEFAULT_AUTHORITY_STATE); setDraft(DEFAULT_AUTHORITY_STATE); setLoadFailed(false); setLoadedFor(relatedPersonId); setSaveError(null); setChoosingAgain(true); }; const changeTier = (tier: AuthorityTier) => setDraft((current) => ({ ...current, tier, allowMessaging: messagingAfterTierChange(current.tier, tier, current.allowMessaging), })); // Turning See invoices off turns Pay off with it, in the draft as well as the request. const changeBillingView = (checked: boolean) => setDraft((current) => ({ ...current, allowBillingView: checked, allowBillingPay: checked && current.allowBillingPay, })); /** Save the staged choices, or finish a change the server never confirmed, exactly as it was sent. */ const save = async (finish?: UnconfirmedAuthoritySave) => { const current = begin(); const madeFor = viewerEpoch(); let next: CareAccessState; let intent: AuthorityIntent; if (finish) { next = { tier: finish.tier, allowMessaging: finish.allowMessaging, allowBillingView: finish.allowBillingView, allowBillingPay: finish.allowBillingPay }; intent = finish.intent; } else { const billing = normalizeBillingGrant(draft.allowBillingView, draft.allowBillingPay); next = { ...draft, ...billing }; const key = JSON.stringify([madeFor, relatedPersonId, generation, next.tier, effectiveAllowMessaging(next.tier, next.allowMessaging), billing]); if (intentRef.current?.key !== key) intentRef.current = { key, intent: newAuthorityIntent(generation) }; intent = intentRef.current.intent; } const sent = { tier: next.tier, allowMessaging: effectiveAllowMessaging(next.tier, next.allowMessaging), allowBillingView: next.allowBillingView, allowBillingPay: next.allowBillingPay && next.allowBillingView, }; setSaving(true); setSaveError(null); // Recorded before it is sent, under this viewer: a page that goes away, or another context, before the // answer cannot lose the one identity that can finish it. const record = recordAuthoritySave({ relatedPersonId, intent, ...sent, ...(finish?.attestation ? { attestation: finish.attestation } : {}) }); const answer = putAuthority( intent, relatedPersonId, sent.tier, sent.allowMessaging, sent.allowBillingView, sent.allowBillingPay, ...(finish?.attestation ? [finish.attestation] : []), ); // The answer settles the record whenever it comes, for the viewer that made it, wherever this page has gone // since and even after this card stopped waiting: applied or ended is settled, anything else stays to finish. void answer.then( () => record?.settle(), (error) => { const ended = authoritySaveOutcome(error); if (ended !== "unconfirmed" && ended !== "unanswered") record?.settle(); }, ).finally(() => record?.answered()); try { // Waited for only so long: past it the save is not confirmed (it may yet land), and is offered again as itself. await withinDeadline(answer, AUTHORITY_SAVE_DEADLINE_MS, "access save"); if (!current()) return; if (intentRef.current?.intent === intent) intentRef.current = null; setUnfinished(undefined); setLoadFailed(false); setSaved(next); setDraft(next); toast.success("Saved.", { position: "top-right" }); onSaved?.(next); void read(true); } catch (error: unknown) { const outcome = authoritySaveOutcome(error); if (!current()) return; console.error("Failed to save care access grant:", error); if (outcome === "unconfirmed" || outcome === "unanswered") { // It may have been applied. Neither "saved" nor "nothing changed": the same change is offered again. setUnfinished(unconfirmedFor(relatedPersonId)); setSaveError("We couldn't confirm this was saved. Try again to finish; it will only be applied once."); } else if (outcome === "superseded") { // Another decision came first. Nothing more is sent for this one; what is in force now is read and shown. intentRef.current = null; setUnfinished(undefined); setSaveError("Their access was changed elsewhere first, so nothing was saved. What is in force now is shown."); setLoadAttempt((attempt) => attempt + 1); } else { // A refusal is final for this decision. intentRef.current = null; setUnfinished(undefined); setSaveError(`Nothing was changed: ${relayServerFailure(error, "the save did not go through")}`); } } finally { if (current()) setSaving(false); } }; const spinner = (loading || saving) && (