@financedistrict/apps-idle-session
v0.4.1
Published
Cross-tab idle-timeout detection for FD apps — tracks aggregate inactivity across every open tab on the same origin and fires a callback once none of them have seen activity for the configured timeout.
Readme
@financedistrict/apps-idle-session
Cross-tab idle-timeout detection for FD apps. Every FD app that mounts this
hook on the same origin (e.g. everything under apps.test.1stdigital.tech)
shares one idle clock: activity in any open tab keeps the session alive;
only once every tab has been quiet for the configured timeout does
onIdle fire.
Why cross-tab, not per-tab
A per-tab timer logs a user out while they're actively working in a
different tab on the same origin — this package exists specifically to avoid
that. It broadcasts activity through a shared localStorage key (not the
usual app-namespaced kind — the whole point is that every app reads and
writes the same one) and reconciles across tabs via the storage event, plus
a visibilitychange recheck to catch up when a backgrounded tab's own timer
gets throttled by the browser.
Usage
import { useIdleSession } from "@financedistrict/apps-idle-session";
import { useMsal } from "@azure/msal-react";
function App() {
const { instance } = useMsal();
const { isWarning, secondsRemaining } = useIdleSession({
timeoutMs: 16 * 60 * 1000, // 16 minutes total
warningMs: 60 * 1000, // last 60s of that shows a warning
onIdle: () => instance.logoutRedirect(),
});
return (
<>
<Routes />
{isWarning && <IdleWarningDialog secondsRemaining={secondsRemaining} />}
</>
);
}Mount the hook once, near the root, alongside the other session-scoped
providers. onIdle is expected to end the session — an MSAL logout is the
norm — the hook itself never touches auth state or calls MSAL. Rendering a
countdown UI for isWarning is the caller's job too (apps-ui's
IdleWarningDialog preset, built for this).
API
const { isWarning, secondsRemaining } = useIdleSession({
timeoutMs: number; // required — ms of aggregate cross-tab inactivity before onIdle fires
onIdle: () => void; // required — called once, in every open tab, when the timeout is reached
warningMs?: number; // default: none — report isWarning starting this many ms before timeoutMs
activityEvents?: string[]; // default: mousedown, mousemove, keydown, scroll, touchstart, wheel
activityThrottleMs?: number; // default: 1000 — how often activity is broadcast to other tabs
checkIntervalMs?: number; // default: 5000 — how often each tab re-derives elapsed idle time
storagePrefix?: string; // default: "fd:idle-session" — shared across every app on the origin;
// override only if this app deliberately wants its own, isolated idle clock
enabled?: boolean; // default: true
});Returns { isWarning: boolean; secondsRemaining: number | null }. Without
warningMs, isWarning stays false and secondsRemaining stays null for
the hook's whole lifetime — the pre-warning behavior, unchanged. With
warningMs set, isWarning flips to true once timeoutMs - warningMs has
elapsed since the last activity, and secondsRemaining ticks down once a
second from there (a separate, faster internal poll than checkIntervalMs —
otherwise a countdown driven by the default 5s check would visibly jump in
5-second steps). Any activity — local or broadcast from another tab — clears
isWarning immediately, without waiting for the next tick.
onIdle fires once per tab, per idle period. A tab that already fired won't
fire again until fresh activity resumes the shared clock — in practice a page
reload after logout resets this naturally.
If you pass a custom activityEvents array, memoize it (useMemo): it's a
dependency of the hook's internal effect, so a fresh array identity on every
render resubscribes the listeners every render.
What this package does not do
- It does not call MSAL, or decide what "logged out" means for your app —
that's
onIdle's job. - It does not render anything.
isWarning/secondsRemainingare state, not UI — pair withapps-ui'sIdleWarningDialog(or your own) to actually show a countdown. - Like a route guard, this is a UX affordance, not a security control: actual session and token lifetime are enforced by MSAL and the BFF, not by this timer. A tampered or bypassed client-side timer never grants extra access.
useStaySignedIn — the 24h auto-logout grant
A separate, related hook for a device-level "disable auto-logout" grant.
There is no login-time prompt (retired in ADR-0098): Entra already keeps
the user signed in, so there was nothing to confirm right after login — the
only real decision point is the idle-warning countdown itself, so the grant
is offered from IdleWarningDialog's checkbox, not a separate dialog.
import {
useIdleSession,
useStaySignedIn,
} from "@financedistrict/apps-idle-session";
import { IdleWarningDialog } from "@financedistrict/apps-ui/idle-warning-dialog";
function App() {
const { isAuthenticated, instance } = useAuth();
const { isSuppressingIdleLogout, acceptStaySignedIn } = useStaySignedIn({
enabled: isAuthenticated,
});
const { isWarning, secondsRemaining } = useIdleSession({
timeoutMs: 31 * 60 * 1000,
warningMs: 60 * 1000,
onIdle: () => instance.logoutRedirect(),
enabled: isAuthenticated && !isSuppressingIdleLogout,
});
return (
<>
<Routes />
<IdleWarningDialog
open={isWarning}
secondsRemaining={secondsRemaining ?? 0}
onSignOut={() => instance.logoutRedirect()}
onContinue={(disableAutoLogoutFor24h) => {
if (disableAutoLogoutFor24h) acceptStaySignedIn();
}}
/>
</>
);
}const { isSuppressingIdleLogout, acceptStaySignedIn } = useStaySignedIn({
enabled?: boolean; // default true — gate this on isAuthenticated
staySignedInMs?: number; // default DEFAULT_STAY_SIGNED_IN_MS (24h) — leave at the
// shared default so an accepted grant means the same thing in every app
storagePrefix?: string; // default: same "fd:idle-session" family as useIdleSession —
// "stay signed in" is a device-level intent, meant to apply to every FD app
});isSuppressingIdleLogoutis what actually gatesuseIdleSession'senabled. Re-checked periodically (every 60s), so a grant correctly expires on its own afterstaySignedInMseven if the tab is never reloaded in between.acceptStaySignedIn()starts a newstaySignedInMsgrant, immediately, in every tab on this origin. Call it whenIdleWarningDialog'sonContinuefires with its checkbox checked — there is no separate "don't show again" preference to thread through; the grant itself is the only state that persists.- One thing this can't do: "stay signed in" only ever means "don't run our own idle-logout for a while." It has no control over Entra's actual session/token lifetime — if that expires on its own first (tenant sign-in-frequency policy), the user is signed out regardless of any active grant here.
