"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import { policySubjectForClient } from "@/lib/admin/policy-subject"; import type { PolicySubject } from "@/lib/admin/policy-scopes"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { flushComposers } from "@/lib/compose-flush-registry"; import { openActivityChannel, readLastActivity, type ActivityChannel } from "@/lib/session-policy/activity-channel"; import { NO_SESSION_LIMITS, sameLimits, sessionLimits, setCurrentSessionLimits, type AccountSession, type SessionLimits, } from "@/lib/session-policy/limits"; import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer"; import { rememberSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason"; import { SessionTimeoutDialog } from "./session-timeout-dialog"; /** How long the sign-out waits for the open composers to save their drafts (and for sends in flight). */ export const DRAFT_FLUSH_TIMEOUT_MS = 8000; /** Local activity reaches the timer and the other tabs at most this often. */ const ACTIVITY_THROTTLE_MS = 1000; /** * Bubbling input events, caught on the way down. Scrolling and focus moves * inside the page don't count: the page scrolls and refocuses on its own. * Input inside a frame (the office editor, plugin panels) never reaches this * window and doesn't count either: a focused frame left alone would keep the * session open forever. Users working there see the warning, which sits * above every overlay, and choose to stay. */ const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const; /** Policy subjects per client and connection round; a failed or empty answer is asked again. */ const subjects = new WeakMap }>(); function subjectFor(client: IJMAPClient, revision: number): Promise { const cached = subjects.get(client); if (cached && cached.revision === revision) return cached.subject; const subject = policySubjectForClient(client).catch(() => null); subjects.set(client, { revision, subject }); void subject.then((value) => { if (value === null && subjects.get(client)?.subject === subject) subjects.delete(client); }); return subject; } /** The limits of every account signed in here, each with its own domain and group scopes. */ async function collectLimits(revision: number): Promise { const auth = useAuthStore.getState(); const clients = new Map(auth.getAllConnectedClients()); if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client); const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => { const subject = await subjectFor(client, revision); const session = usePolicyStore.getState().resolveFor(subject).organisation?.session; return { session, signedInAt: signedInAtFor(accountId) }; })); return sessionLimits(accounts); } /** * When the account signed in. An account signed in before this was recorded * counts as signed in now, the first time it is seen. */ function signedInAtFor(accountId: string): number | undefined { const store = useAccountStore.getState(); const account = store.getAccountById(accountId); if (!account) return undefined; if (typeof account.signedInAt === "number") return account.signedInAt; const now = Date.now(); store.updateAccount(accountId, { signedInAt: now }); return now; } function endReason(kind: SessionEndKind, limits: SessionLimits, draftsUnsaved: boolean): SessionEndReason { const drafts = draftsUnsaved ? { draftsUnsaved: true } : {}; return kind === "max" ? { kind: "max", hours: limits.max?.hours ?? 0, ...drafts } : { kind: "idle", minutes: limits.idle?.minutes ?? 0, ...drafts }; } const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null }; /** * The organisation's idle timeout and maximum session length * (policy.json `organisation.session`). Activity in any Bulwark tab keeps * every tab signed in; a warning counts down first; at zero the open and * minimised composers save their drafts and every account signs out, with * the reason on the login page. */ export function SessionTimeoutGuard() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isDemoMode = useAuthStore((s) => s.isDemoMode); const client = useAuthStore((s) => s.client); const revision = useAuthStore((s) => s.connectedAccountsRevision); const rawPolicy = usePolicyStore((s) => s.rawPolicy); const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(",")); // The last activity any tab recorded, read once before this tab's own // listeners can write it: a reload after a pause longer than the limit // signs out right away, whatever the user does while the page loads. const [mount] = useState(() => ({ at: Date.now(), lastActivity: readLastActivity() })); const [limits, setLimits] = useState(NO_SESSION_LIMITS); const [limitsReady, setLimitsReady] = useState(false); const [state, setState] = useState(IDLE_STATE); const [ending, setEnding] = useState(false); const [dismissedMax, setDismissedMax] = useState(null); const timerRef = useRef(null); const timersStartedRef = useRef(0); const channelRef = useRef(null); const limitsRef = useRef(limits); limitsRef.current = limits; const limitsReadyRef = useRef(limitsReady); limitsReadyRef.current = limitsReady; const endingRef = useRef(false); const active = isAuthenticated && !isDemoMode; useEffect(() => { if (!active) { setLimits(NO_SESSION_LIMITS); setLimitsReady(false); return; } let cancelled = false; void collectLimits(revision).then((next) => { if (cancelled) return; setLimits((prev) => (sameLimits(prev, next) ? prev : next)); setLimitsReady(true); }); return () => { cancelled = true; }; }, [active, client, revision, rawPolicy, accountsKey]); useEffect(() => { setCurrentSessionLimits(active ? limits : null); }, [active, limits]); const endSession = useCallback(async (kind: SessionEndKind) => { if (endingRef.current) return; endingRef.current = true; setEnding(true); const current = limitsRef.current; // Said first, in case another tab's sign-out takes this one to the login page meanwhile. rememberSessionEnd(endReason(kind, current, false)); const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS); if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true)); await useAuthStore.getState().logoutAll({ endProviderSession: false }); }, []); // The clock, while there is a limit to count. const hasLimits = active && limitsReady && (!!limits.idle || !!limits.max); useEffect(() => { if (!hasLimits) { timerRef.current?.dispose(); timerRef.current = null; setState(IDLE_STATE); return; } if (timerRef.current) { timerRef.current.setLimits(limits); return; } // The first clock of this page starts from the activity recorded before // the page loaded, a fresh sign-in from the sign-in. A clock started // later (limits turned on meanwhile) from what the tabs recorded since. const stored = timersStartedRef.current === 0 ? mount.lastActivity ?? mount.at : readLastActivity() ?? Date.now(); const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0)); timersStartedRef.current += 1; timerRef.current = createSessionTimer({ limits, lastActivity: Math.max(stored, newestSignIn), onChange: setState, onExpire: (kind) => { void endSession(kind); }, }); setState(timerRef.current.state()); }, [hasLimits, limits, endSession, mount]); useEffect(() => () => { timerRef.current?.dispose(); timerRef.current = null; }, []); useEffect(() => { if (!active) { endingRef.current = false; setEnding(false); } }, [active]); // Activity in this tab and the others. useEffect(() => { const channel = openActivityChannel((at) => timerRef.current?.remoteActivity(at)); channelRef.current = channel; let lastSeen = 0; const onActivity = () => { // Nothing counts until the limits of the signed-in accounts are known: // the clock starts from what was recorded before. if (endingRef.current || !limitsReadyRef.current) return; const at = Date.now(); if (at - lastSeen < ACTIVITY_THROTTLE_MS) return; const timer = timerRef.current; if (timer) { timer.activity(at); // During the idle warning only "Stay signed in" counts. const s = timer.state(); if (s.phase === "expired" || (s.phase === "warning" && s.kind === "idle")) return; } lastSeen = at; channel.post(at); }; const onVisibility = () => { if (document.visibilityState === "visible") timerRef.current?.check(); }; for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true }); // The window coming back into focus, not focus moving between elements. window.addEventListener("focus", onActivity); document.addEventListener("visibilitychange", onVisibility); return () => { for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true }); window.removeEventListener("focus", onActivity); document.removeEventListener("visibilitychange", onVisibility); channel.close(); channelRef.current = null; }; }, []); const stay = useCallback(() => { const timer = timerRef.current; if (!timer) return; timer.stay(); channelRef.current?.post(timer.lastActivity()); }, []); const signOutNow = useCallback(() => { const kind = timerRef.current?.state().kind ?? "idle"; void endSession(kind); }, [endSession]); if (!active) return null; if (ending) { return ; } if (state.phase !== "warning" || !state.kind) return null; if (state.kind === "max" && dismissedMax === state.deadline) return null; return ( setDismissedMax(state.deadline)} onSignOut={signOutNow} /> ); }