import { useCallback, useEffect, useState } from 'react'; import { ShieldAlert } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Button } from '../ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { useOrgAuthorizationServiceContext, type OrgAuthorizationAgreement, type OrgAuthorizationStatusValue, } from '../../services/OrgAuthorizationService.tsx'; import { useAuthServiceContext } from '../../services/AuthService.tsx'; import { useFirstSignInGateStatus } from '../FirstSignInGate/firstSignInGateStatus.tsx'; import { HttpError } from '../../services/HttpService.tsx'; import AgreementText from './AgreementText.tsx'; import { isPermanentDenial, relayServerFailure } from '../../utils/fetchFailureMessage'; import { RECORDS_INSTRUMENT } from '../../config/consentInstruments.ts'; // SUPERSEDED AND NO LONGER MOUNTED ANYWHERE. `components/OrgAgreements/` is the // patient-facing agreements gate now, and it is not a redesign of this: it asks about THREE documents // with a separate answer each, it lives in the banner slot on every screen rather than opening itself as // a dialog on arrival, and it cannot be dismissed to a card on one page. // // DO NOT RE-MOUNT THIS. Two consent surfaces asking one patient about overlapping documents means // somebody answers here and is asked again there. It is kept for one release only so the replacement can // be walked against a working fallback, and its behaviours worth having - the 401/403 "not yours to // answer" silence, the permanent-denial wording, the withdrawn-and-declined silence - are already // carried across. // // Phase 2 (clinical-authorization gate), client Task 9 - the patient-facing portal prompt to // grant clinical authorization. Mirrors ProofingBanner.tsx's status-driven shape (fetch on mount, // render nothing for terminal states, fail toward SHOWING on a status-fetch error), but the // gesture is a dialog (agree/attest) rather than a redirect. // // Mounted in DashboardPage's global per-page shell, gated on either a direct patient login or a // related person acting on a patient's behalf (both populate PatientService's `patientState`) - // this covers the patient's own view AND the "helping {Patient}" full-proxy acting context with a // single mount site. Deliberately NOT duplicated onto ProfilePage.tsx the way ProofingBanner is: // ProfilePage is itself rendered through DashboardPage's , so mounting there too would // double-render this prompt on the profile route; ProfilePage's own RelatedPerson branch is about // the related person's own identity display, not the patient-authorization context this prompt // gates on. // // `getStatus()`/`agree()` are called with NO patientId - the server resolves the caller's own // patient (direct login) or the proxied patient (a related person acting on their behalf) from // auth context, so no client-side patient-id plumbing is needed for the full-proxy case either. const OrgAuthorizationPrompt = () => { const { getStatus, getAgreement, agree } = useOrgAuthorizationServiceContext(); const { wpOrganization } = useAuthServiceContext(); // 'loading' and 'forbidden' are local sentinels (not OrgAuthorizationStatusValues). 'loading' // renders nothing during the initial fetch instead of flashing the dialog before we know the real // status; 'forbidden' records that the server refused to tell this caller at all - deliberately // NOT reused as 'none', which would claim we know there is no authorization when in fact we know // nothing about it. const [status, setStatus] = useState<'loading' | 'forbidden' | OrgAuthorizationStatusValue>('loading'); const [agreement, setAgreement] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); // Set only by an explicit "Not now" - distinct from "the dialog just hasn't opened yet" so the // banner never flashes during the brief proposed-but-not-yet-loaded window on mount. // - THE MANDATORY GATE GOES FIRST. This dialog is optional and dismissible; the Akashic // agreements gate is neither. Until that gate reports 'clear' this component does nothing at all - no // fetch, no dialog, no banner. // The fix is ORDERING, not layering. Both dialogs carried `z-index: 1100`, so which one won was decided // by DOM order alone; this one happened to be on top and made the Terms gate's Accept button unreachable // (proven with `elementFromPoint()`, and flipped back by dismissing this dialog). Raising the other's // z-index would have kept the accident and just picked a side - and left a patient being asked to open // their record to an ORGANIZATION before they had accepted, or could even read, Akashic's own Terms. // It re-runs when the gate resolves, so a patient who accepts both agreements is offered this // immediately afterwards, in the same session - not on their next visit. const firstSignInGateStatus = useFirstSignInGateStatus(); const mandatoryGateIsClear = firstSignInGateStatus === 'clear'; const [dismissed, setDismissed] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(() => { // Returning BEFORE the fetch, not merely hiding the result. This request is org-scoped and // authenticated; firing it behind a gate the user has not answered is exactly the ordering the card // is about, and 'unknown' (the gate has not finished asking) must be treated like 'outstanding'. if (!mandatoryGateIsClear) return; let cancelled = false; (async () => { let effectiveStatus: 'forbidden' | OrgAuthorizationStatusValue; try { // THREE DOCUMENTS NOW EXIST AND THIS SURFACE STILL ASKS ABOUT ONE: the instrument named here is the HIPAA Authorization because that is the // one whose BEHAVIOUR the old welded "Authorization to Treat" actually had - an unsigned authorization // meant the practice could not see the record, which answers access, on an object whose name said // treatment. Naming it keeps this surface doing exactly what it did. Rebuilding these surfaces around // all three documents is the consent-flow work, not this change. const { status: fetched } = await getStatus(undefined, undefined, RECORDS_INSTRUMENT); effectiveStatus = fetched; } catch (err) { if (err instanceof HttpError && (err.status === 403 || err.status === 401)) { // NOT a transient failure - a definitive "this gate is not yours to answer". // The server admits only the patient themselves or a FULL-PROXY related person // (OrgAuthorization/get.ts; an unproofed proxy is downgraded and refused), so this // caller cannot satisfy the prompt even in principle: agree.ts enforces the same // rule, and Authorize would 403 too. // // Treating it as 'proposed' below produced exactly that dead end on staging - an // unproofed related person, acting for a patient who had ALREADY authorized the // organization, was shown an unsatisfiable authorization dialog. Render nothing: // whether this patient has authorized is simply not information we were given. console.warn('Org-authorization status refused for this caller; not rendering the gate.'); effectiveStatus = 'forbidden'; } else { // Fail toward SHOWING the prompt rather than silently hiding a gate that should // block - a transient status-fetch failure shouldn't leave an unauthorized action // looking clear when it isn't (mirrors ProofingBanner's fail-open direction). console.error('Failed to load org-authorization status:', err); effectiveStatus = 'proposed'; } } if (cancelled) return; setStatus(effectiveStatus); if (effectiveStatus === 'proposed') { try { const fetchedAgreement = await getAgreement(undefined, RECORDS_INSTRUMENT); if (cancelled) return; setAgreement(fetchedAgreement); setDialogOpen(true); } catch (err) { console.error('Failed to load org-authorization agreement:', err); // Fail toward SHOWING something: without this, a proposed gate whose agreement // fetch fails would leave both the dialog AND the banner unrendered - a // mandatory gate silently disappearing until a full page reload. Falling back // to the dismissed banner gives the user a visible, clickable path (via // handleReopen's own getAgreement retry) to recover. if (!cancelled) setDismissed(true); } } })(); return () => { cancelled = true; }; }, [getStatus, getAgreement, mandatoryGateIsClear]); useEffect(() => refresh(), [refresh]); const handleAuthorize = async () => { if (!agreement) return; setSubmitting(true); setError(null); try { const { status: newStatus } = await agree({ captureMethod: 'portal-pending', agreedVersion: agreement.version, // The same document this screen read its status and its text from. A write naming a // different instrument than the read would answer a question nobody was asked. instrument: RECORDS_INSTRUMENT, }); setDialogOpen(false); setStatus(newStatus); } catch (err) { console.error('Failed to record org authorization:', err); // A PERMANENT DENIAL MUST NOT SAY "PLEASE TRY AGAIN", and that sentence is the whole defect. // // Observed on staging 2026-08-29: a clinician opened the patient portal, the dialog read its // own gate happily (GET agreement admits PRACTITIONERS) and then Authorize was refused, // because POST agree admits only PATIENTS and RELATED_PERSONS. The dialog offered a retry for // something that could never succeed, so the only way past the first screen was to decline - // pressing the affirmative button did nothing a reader could act on. // // The cause is fixed on the server side, where every staff account now gets the `Patients` // group with its home Patient. This is the other half: whatever the reason, a gate that // cannot be satisfied has to say so and point somewhere, because the next press of the same // button will fail identically. const permanent = err instanceof HttpError && isPermanentDenial(err.status); setError(permanent ? 'This account cannot authorize on its own behalf. Your practice can record it for you.' : relayServerFailure(err, 'Failed to authorize. Please try again.')); } finally { setSubmitting(false); } }; const handleNotNow = () => { setDialogOpen(false); setDismissed(true); }; const handleReopen = () => { if (agreement) { setDialogOpen(true); return; } // The agreement wasn't available last time we tried (e.g. a transient fetch failure) - // retry rather than opening an empty dialog. void (async () => { try { const fetchedAgreement = await getAgreement(undefined, RECORDS_INSTRUMENT); setAgreement(fetchedAgreement); setDialogOpen(true); } catch (err) { console.error('Failed to load org-authorization agreement:', err); } })(); }; // - `withdrawn` RETURNS NULL, and it is the reason the fourth status exists. // // Before it did, a withdrawn authorization came back from the server as `proposed`, so this prompt - // which exists to ask a patient who has not answered yet - opened on the patient who HAD answered, // seconds after they answered. It reads as though the withdrawal did not take, and the only way out of // it is to agree again. // // Silence here is not the same as hiding the control. A patient who withdrew and wants back in goes // to the practice on My Providers and presses "Allow access again" - an action they went looking for, // rather than a dialog that meets them at the door and asks them to reverse a decision they just made. // // THIS LINE IS THE SECOND LOCK, NOT THE FIRST, and a mutation pass is how that was established: deleting // `withdrawn` from this condition changes nothing observable, because the effect above only fetches the // agreement and opens the dialog when the status is exactly `proposed`. That check is the load-bearing // one. Keeping this one anyway is deliberate - a future edit that widens the effect's condition (say to // "anything not active") would otherwise silently start nagging withdrawn patients. // second lock. The effect above already declines to fetch, so `status` stays 'loading' and this // returns null anyway - but a future edit that seeds status from somewhere else must not be able to put // this dialog back on top of a mandatory gate. if (!mandatoryGateIsClear) { return null; } // - `declined` RETURNS NULL FOR THE SAME REASON AS `withdrawn`, and it is the state this // prompt used to nag hardest. A decline came back from the server as `proposed`, so this dialog met the // patient at the door on their next sign-in and asked the question they had already answered - which is // the product not listening, and it is what Spencer's fence forbids: a decline is an ANSWER, not a lead. // Silence here is not hiding the control. A patient who declined and changes their mind finds // "you declined - you can change this" on the practice's own card in My Providers, an action they went // looking for, exactly as a withdrawn patient does. Same reasoning, same place, deliberately. if (status === 'loading' || status === 'forbidden' || status === 'active' || status === 'none' || status === 'withdrawn' || status === 'declined') { return null; } // status === 'proposed' const orgName = wpOrganization.current?.name || 'this organization'; return ( <> { if (!next) handleNotNow(); }} > Authorize {orgName} Review and agree to {orgName}'s clinical authorization to continue care. {agreement && } {error &&

{error}

}
{dismissed && !dialogOpen && ( { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleReopen(); } }} > Authorization needed {orgName} needs your authorization before continuing. Click here to review. )} ); }; export default OrgAuthorizationPrompt;