import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ShieldAlert } from 'lucide-react'; import { UNNUMBERED_SESSION, sessionTokenIsGone, storedSessionId, watchForSessionEndedElsewhere, } from '../../services/sessionEndedElsewhere'; import { isolateBackground } from '../../utils/isolateBackground'; /** * COVER THE RECORD WHEN THE SESSION UNDER IT IS GONE. * * The idle timeout exists because a front desk is a shared, semi-public machine: somebody steps away, an * hour passes, the session ends. The tab that timed out signs itself out and reloads. Every OTHER tab on * that machine kept rendering the chart, the schedule and the patient's name, with the tokens already * deleted underneath - so the protection and the thing it was protecting were separated by a click * nobody was there to make. * * COVERED, NOT DISCARDED, and that is the whole shape of this. Redirecting the stale tab would take a * half-written note or an unsaved intake form with it, on a tab whose owner is not there to be asked. * The tree stays mounted and the route is untouched; an opaque layer goes over the top. Nothing readable, * nothing lost. * * IT IS ABOVE THE DIALOGS ON PURPOSE. The screen this was reported on had an open Manage Schedule dialog * at z-50; a cover underneath that would have left the one panel with a patient's name on it showing. * * WHAT IT DOES NOT COVER, said plainly: the address bar. A stale tab still reads * `#/dashboard/patients/` until somebody leaves the page, and dropping the hash is what a reload does * - which is the redirect this shape exists to avoid. Signing in again resolves it; until then this * hides the record and not the route. */ const SessionEndedOverlay = ({ signedIn }: { signedIn: boolean }) => { const [ended, setEnded] = useState(false); const actionRef = useRef(null); // THE SESSION THIS VIEW IS SHOWING, captured when it is first signed in. A page that never held one is never // covered, and a later session in storage (somebody signing in elsewhere) does not vouch for this view. const shown = useRef(undefined); if (signedIn && shown.current === undefined) { shown.current = sessionTokenIsGone() ? 'gone' : storedSessionId() ?? UNNUMBERED_SESSION; } useEffect(() => watchForSessionEndedElsewhere(() => setEnded(true), () => shown.current), [signedIn]); // ITS OWN LAYER ON THE BODY, so everything else (the app, an open dialog, one opened later) can be made inert // and hidden from assistive technology while the tree stays mounted with any unsaved work in it. Applied // before paint, so there is no frame in which the record is covered but still reachable. const [host] = useState(() => (typeof document === 'undefined' ? null : document.createElement('div'))); useLayoutEffect(() => { if (!ended || !host) return; // Body portals cannot inherit the plugin's scoped utilities or theme tokens. Object.assign(host.style, { position: 'fixed', inset: '0', zIndex: '2147483647', backgroundColor: 'Canvas', color: 'CanvasText', fontFamily: 'system-ui, sans-serif', }); const root = document.getElementById('akashic-health-records-patient-portal-root'); if (root) { const theme = getComputedStyle(root); for (const token of ['--paper', '--ink', '--ink-muted', '--blue']) { const value = theme.getPropertyValue(token).trim(); if (value) host.style.setProperty(token, value); } if (theme.fontFamily) host.style.fontFamily = theme.fontFamily; } document.body.appendChild(host); const release = isolateBackground(host); return () => { release(); host.remove(); }; }, [ended, host]); // Focus moves to the only thing there is to do. Without it a screen reader keeps reading the record // underneath, which is the same disclosure this component exists to stop, one sense over. useEffect(() => { if (ended) actionRef.current?.focus(); }, [ended]); if (!ended || !host) return null; // The same landing a deliberate sign-out uses: back to the portal page with the hash dropped, so the // address bar stops naming the patient. Deliberately NOT `logout()` - the tokens are already gone, // and calling it again would send a second sign-out beacon and write a duplicate audit row for one // event. const signInAgain = () => { window.location.href = `${window.location.pathname}${window.location.search}`; }; return createPortal(
, host, ); }; export default SessionEndedOverlay;