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 (
<>
{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;