// Related-person "act-as" account switcher, client Task 5 (final Phase 1 task) - the // persistent "you're viewing someone else's account" banner. Self-contained: reads actingAs from // useActAs() itself (no props) so it drops in once anywhere in the shell and Just Works, mirroring // ProofingBanner/ConfirmActAsDialog's "own the hook call" pattern from Tasks 3/7. // // Deliberately distinct from the legacy RELATED_PERSON `Notice` in DashboardPage: that block gates // on the old login-as-RP role and is retired in a later phase; this gates on the new `actingAs` // state set by useActAs().enter(). The two are mutually exclusive today (switching in doesn't touch // activeUserRole) but are NOT the same ceremony - this one is sticky/unmissable by design since // acting-as can be entered from deep in the app, not just at login. import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { LogOut, UserRoundCog } from 'lucide-react'; import { Button } from '../ui/button'; import { useActAs } from '../../hooks/useActAs'; // How often a session acted in is confirmed while the page is in view: a grant revoked by the patient ends // within this, not when the helper next happens to focus the window. const REVALIDATE_MS = 20_000; const ActingAsBanner = () => { const { actingAs, exit, revalidate, isEntering, actingAsEnded, acknowledgeActingAsEnded, actingAsEntryNotice, acknowledgeActingAsEntryNotice, } = useActAs(); // Guards against stacking revalidate() calls (A2): a second window `focus` while one is still // in flight is ignored rather than firing a redundant re-GET. A plain ref (not state) is enough // since nothing here needs to re-render off it. const revalidatingRef = useRef(false); // The check in flight, aborted when the session it was asked in ends here or the banner goes. const inFlightRef = useRef(null); const revalidateRef = useRef(revalidate); revalidateRef.current = revalidate; // Every trigger comes through here: focus, return, the schedule and navigation. One check at a time, none // while the page is hidden, and each one bounded by revalidate's own deadline, so the slot always frees. const checkRef = useRef(() => { if (revalidatingRef.current || document.visibilityState === 'hidden') return; revalidatingRef.current = true; const controller = new AbortController(); inFlightRef.current = controller; // The notice is set by the state funnel, not here: revalidate ends a session by // clearing `actingAs`, and clearing it is what records who it ended. void revalidateRef.current(controller.signal).finally(() => { // Only the check still owned here is released, and its request with it: a check that timed out // leaves no request running behind it, and an older one never touches its replacement. if (inFlightRef.current !== controller) return; controller.abort(); inFlightRef.current = null; revalidatingRef.current = false; }); }); // READ FROM THE SHARED STATE RATHER THAN HELD HERE, and that is the correction that matters. // It was local state set only by this component's own revalidate, so the paths that end a // session somewhere else - a Patient fetch three providers away - cleared the banner and set // nothing to replace it. The notice now lives beside `actingAs` itself, where every writer // reaches it by default. // Mid-session revocation catch ( fast-follow A2): re-confirm the acting-as grant is // still honored whenever the window regains focus, not just on the next navigation. Gated on // actingAs so it's only ever wired up while actually acting-as, and torn down (listener removed) // the moment that's no longer true or this unmounts. useEffect(() => { if (!actingAs) { return; } const handleFocus = () => checkRef.current(); // Also on coming back to the page and on a bounded schedule while it is in view, one check at a time. const timer = window.setInterval(handleFocus, REVALIDATE_MS); window.addEventListener('focus', handleFocus); document.addEventListener('visibilitychange', handleFocus); return () => { window.clearInterval(timer); window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleFocus); // The session this check was asked in is over here: its answer is retired and the slot is free. inFlightRef.current?.abort(); inFlightRef.current = null; revalidatingRef.current = false; }; }, [actingAs]); // And on every move to another page of theirs, before it shows anything of the person acted for. const { pathname } = useLocation(); const firstPath = useRef(true); useEffect(() => { if (firstPath.current) { firstPath.current = false; return; } if (actingAs) checkRef.current(); // eslint-disable-next-line react-hooks/exhaustive-deps -- runs on navigation, not on every render }, [pathname]); // Optimistic-flash guard (A3b): actingAs is set BEFORE enter()'s validate GET resolves, so // suppress the banner for that in-flight window - otherwise a bad target flashes "Viewing X" // right before the 403 reverts it. // THE GRANT ENDED WHILE THEY WERE READING. Rendered in place of the banner, in the same // position, and it does not go away on its own: the guardian is now looking at their own // record, and every protection in this flow exists to stop them believing otherwise. Red // rather than amber, because amber is the state they were in a moment ago and the difference // between the two is the whole message. if (actingAsEnded) { return (
); } // A switch that did not happen. Not an ended grant: they were never in, and an unanswered check is not a refusal. if (actingAsEntryNotice && !actingAs) { return (
); } if (!actingAs || isEntering) { return null; } const subjectLabel = actingAs.subjectName || 'another account'; return (
{/* The shared notice aligns its children to the top, so the row centres itself here. */}
Viewing {subjectLabel}'s account
); }; export default ActingAsBanner;