import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; /** * - sign an UNATTENDED session out, and never a busy one. * * > Spencer, 2026-08-13, confirmed twice: **60 minutes, staff and patients alike.** * * AN IDLE TIMEOUT IS NOT A TOKEN LIFETIME. This confusion has already cost one round. * * | lever | value | what it governs | * |---|---|---| * | access token | 30 min | how long a **stolen** token works. Invisible to users - it refreshes silently | * | refresh token | 7 days | how long you may be **away** and still return signed in | * | **this** | 60 min | how long an **unattended** session survives | * * Raising the access token to buy a friendlier idle experience buys NOTHING - expiry is already * invisible - and doubles the window a scraped token is usable. And this must not be built by holding * the refresh token in memory instead of `localStorage`: rotation is safe at a 30-second grace period * *because* the token is read from storage at refresh time, which is what lets a second tab read what the * rotating tab wrote. * * 60 IS DEFENSIBLE BECAUSE THIS IS A BACKSTOP, NOT THE FRONT LINE * * A practice's own workstation lock is the primary control for an unattended terminal and typically fires * in 5-15 minutes. And our sign-in is **email OTP**, so an idle logout is expensive: the user leaves the * app, opens an inbox, finds a code. An aggressive number here is an adoption risk at exactly the busy * front desk the beta strategy is built around. * * THE THREE CONDITIONS ARE THE WORK. THE NUMBER WAS THE EASY PART. * * 1. **"Idle" means no ACTIVITY, not no navigation - and some activity has NO input.** A live telehealth * visit and a running scribe recording are the product working perfectly with nobody touching a key. * Signing a clinician out mid-visit is the worst failure this feature could have, on the feature the * product is betting on. `useIdleActivityHold`, below. **It is also what makes 60 safe**: during a * real visit the recording is running, so the clock is not. * 2. **Unsaved work must survive it.** The encounter note auto-saves server-side. Anything * that does not auto-save must, before this reaches a customer, or the timeout becomes a defect * generator. * 3. **Never silent.** A warning ahead of time with an "I'm still here" button, and a login page that says * the session ended for inactivity. A bounce with no explanation reads as a bug and gets reported as * one - correctly. */ /** Spencer's ruling, 2026-08-13. Not a token TTL - see the header before changing it. */ export const IDLE_TIMEOUT_MS = 60 * 60 * 1000; /** * How long before the sign-out the warning appears. * * Two minutes: long enough to notice and press a button after looking away, short enough that the * dialog is not sitting on screen for a meaningful slice of the hour. It is subtracted FROM the 60, not * added to it - a warning that extended the session would make the ruled duration a lie. */ export const IDLE_WARNING_LEAD_MS = 2 * 60 * 1000; /** * How often the clock is checked. * * 15s against what it costs: one comparison of two numbers, no render unless a threshold is crossed. * Deliberately NOT a timer set to fire at the deadline: `setTimeout` does not run while a laptop is * asleep, so a machine closed at minute 5 and opened at minute 90 would owe 55 more minutes of idle. A * poll comparing wall-clock timestamps is correct across sleep, and that is the case this exists for. */ const TICK_MS = 15 * 1000; /** * Events that count as a human being present. * * NOT `mousemove` or `scroll`: a nudged desk or a phone in a pocket keeps a lobby terminal signed in * for ever, which is the exact scenario this card was raised about. Intent-bearing events only. * `visibilitychange` is deliberately absent for the same reason - a background tab becoming visible * because the OS switched apps is not a person choosing to use this. */ const ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'wheel', 'touchstart'] as const; // The idle sign-out no longer owns a flag of its own. It records a reason through // `services/sessionEndReason`, alongside the three other involuntary exits that used to say nothing - // one key, one sentence, one alert. A second boolean here is how two explanations for one sign-out both // render at once. interface IdleTimeoutApi { /** * Hold the clock open while something is happening that produces no input. * * Returns a release function. Balanced calls - the clock runs again only when every hold is released. */ hold: (reason: string) => () => void; /** Test/diagnostic surface: which holds are currently open. */ activeHolds: () => string[]; } const IdleTimeoutContext = createContext(undefined); /** * Hold the idle clock open for as long as `active` is true. * * Tolerant of an absent provider by design: this is called from feature components that also render in * tests and on surfaces the provider does not wrap. A missing provider must never take down a video call * or a recording to serve a timeout - the same rule the scribe and auth accessors already state. */ export function useIdleActivityHold(active: boolean, reason: string): void { const ctx = useContext(IdleTimeoutContext); useEffect(() => { if (!active || !ctx) return undefined; return ctx.hold(reason); }, [active, ctx, reason]); } interface ProviderProps { children: ReactNode; /** Called when the session has been idle for the full duration. Must clear the stored tokens. */ onIdleSignOut: () => void; /** Only runs while somebody is signed in - there is nothing to time out otherwise. */ enabled: boolean; /** Injected in tests. */ now?: () => number; } export const IdleTimeoutProvider = ({ children, onIdleSignOut, enabled, now = Date.now }: ProviderProps) => { const lastActivityRef = useRef(now()); const holdsRef = useRef>(new Map()); const holdSeq = useRef(0); const [warning, setWarning] = useState(false); // Read through a ref so the tick effect does not re-subscribe when the callback identity changes. const signOutRef = useRef(onIdleSignOut); signOutRef.current = onIdleSignOut; /** * SIGN OUT ONCE. The tick keeps running until the app tears down, and the real `logout()` ends in * a page reload that takes a moment - so without this the deadline fires again every 15 seconds until * the reload lands, sending a sign-out beacon each time and writing a row of duplicate audit events * for one event. * Found by a test that expected one call and got FIVE. In the app it would have looked like it * worked, because the reload hides it. */ const firedRef = useRef(false); const markActive = useCallback(() => { lastActivityRef.current = now(); // Genuine activity re-arms the sign-out. Without this, a session that expired and was somehow // still on screen could never time out a second time. firedRef.current = false; setWarning((shown) => (shown ? false : shown)); }, [now]); const hold = useCallback((reason: string) => { const id = holdSeq.current++; holdsRef.current.set(id, reason); // A hold beginning is itself activity - otherwise starting a call at minute 59 would warn // immediately and then never resolve. lastActivityRef.current = now(); return () => { holdsRef.current.delete(id); }; }, [now]); const activeHolds = useCallback(() => [...holdsRef.current.values()], []); useEffect(() => { if (!enabled) return undefined; const opts = { passive: true, capture: true } as const; ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, markActive, opts)); return () => ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, markActive, opts)); }, [enabled, markActive]); useEffect(() => { if (!enabled) { setWarning(false); return undefined; } lastActivityRef.current = now(); const tick = setInterval(() => { /* A HOLD DOES NOT PAUSE THE CLOCK - IT KEEPS RESETTING IT. Pausing would mean a call that ended at minute 59 leaves one minute of grace; resetting means the hour starts again when the call ends, which is what "the session was in use until just now" actually means. */ if (holdsRef.current.size > 0) { lastActivityRef.current = now(); setWarning((shown) => (shown ? false : shown)); return; } const idleFor = now() - lastActivityRef.current; if (idleFor >= IDLE_TIMEOUT_MS) { // Not `setWarning(false)` first - this unmounts anyway, and clearing state before the // sign-out would flash the app back to a normal screen on the way out. if (firedRef.current) return; firedRef.current = true; signOutRef.current(); return; } setWarning(idleFor >= IDLE_TIMEOUT_MS - IDLE_WARNING_LEAD_MS); }, TICK_MS); return () => clearInterval(tick); }, [enabled, now]); const api = useMemo(() => ({ hold, activeHolds }), [hold, activeHolds]); return ( {children} {enabled && warning && ( /* CONDITION 3 - NEVER SILENT. Rendered here rather than as a toast: a toast can be missed, auto-dismisses, and stacks behind others. This is the one message whose whole job is to be noticed before something irreversible happens. It does NOT block the app behind a modal overlay: someone who is mid-sentence in a note should be able to keep typing, and typing IS the activity that dismisses it. */

Your session is about to end

You have been inactive for a while. You will be signed out in about two minutes.

)}
); }; export const useOptionalIdleTimeout = (): IdleTimeoutApi | undefined => useContext(IdleTimeoutContext); /** * Holds the clock open for the whole time a video call is live. * * A TELEHEALTH VISIT IS THE CASE THIS FEATURE MOST HAS TO NOT BREAK. A clinician on a call touches * nothing for long stretches - they are listening - and signing them out mid-visit would end the call on * the feature the product is betting on. * * It reads the video service through the OPTIONAL accessor and renders nothing. A missing video * provider must never take down the app, and this component exists solely to observe. * Mounted INSIDE the provider by `IdleTimeoutProvider`'s consumer rather than making the provider * import the video service, so the two stay independent - the idle clock knows about "holds", not about * video. */ export const HoldIdleWhileOnACall = ({ callIsLive }: { callIsLive: boolean }) => { useIdleActivityHold(callIsLive, 'video call'); return null; };