From 629e96b6bdcae032c9710bf6864d81819cc7dc31 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:18:24 +0100 Subject: [PATCH] fix: stop counting focus in the office editor or a plugin frame as activity --- .../__tests__/session-timeout-guard.test.tsx | 34 ++++++++++--------- components/session/session-timeout-guard.tsx | 27 +++------------ 2 files changed, 22 insertions(+), 39 deletions(-) diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx index 6016df443..90b040cd3 100644 --- a/components/session/__tests__/session-timeout-guard.test.tsx +++ b/components/session/__tests__/session-timeout-guard.test.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS, FRAME_FOCUS_CHECK_MS } from '../session-timeout-guard'; +import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountStore, type AccountEntry } from '@/stores/account-store'; import { usePolicyStore } from '@/stores/policy-store'; @@ -271,28 +271,30 @@ describe('session timeout guard', () => { expect(screen.getByText('idle_title')).toBeTruthy(); }); - it('counts the focus on a cross-origin frame as activity, but not on a same-origin one', async () => { + it('does not count a focused cross-origin frame as activity, and warns above its overlay', async () => { const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + // The office editor: a cross-origin frame in a z-[100000] overlay. + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[100000]'; + const editor = document.createElement('iframe'); + Object.defineProperty(editor, 'contentDocument', { get: () => null }); + overlay.appendChild(editor); + document.body.appendChild(overlay); try { await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 })); - const editor = document.createElement('iframe'); - Object.defineProperty(editor, 'contentDocument', { get: () => null }); - document.body.appendChild(editor); editor.focus(); expect(document.activeElement).toBe(editor); - await advance(45 * SECOND); - expect(screen.queryByText('idle_title')).toBeNull(); - - // A message body (same origin) clicked into and left doesn't keep the session. - const body = document.createElement('iframe'); - document.body.appendChild(body); - body.focus(); - await advance(30 * SECOND + FRAME_FOCUS_CHECK_MS); - expect(screen.getByText('idle_title')).toBeTruthy(); - editor.remove(); - body.remove(); + // Left focused, the editor keeps nothing alive: warned at 30 s, signed out at 60 s. + await advance(30 * SECOND); + const warning = screen.getByTestId('session-timeout-dialog'); + expect(Number(/z-\[(\d+)\]/.exec(warning.className)?.[1])).toBeGreaterThan(100000); + // Later in the body than the overlay, so it paints on top at a higher z-index too. + expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await advance(30 * SECOND); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); } finally { + overlay.remove(); hasFocus.mockRestore(); } }); diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx index 2a805f02e..f8a5e6006 100644 --- a/components/session/session-timeout-guard.tsx +++ b/components/session/session-timeout-guard.tsx @@ -28,27 +28,12 @@ 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; -/** - * Input inside a cross-origin frame (the office editor, plugin panels) - * never reaches this window. While such a frame has the focus, that counts - * as activity, checked this often. - */ -export const FRAME_FOCUS_CHECK_MS = 5000; - -function crossOriginFrameHasFocus(): boolean { - if (typeof document === "undefined" || !document.hasFocus()) return false; - const element = document.activeElement; - if (!(element instanceof HTMLIFrameElement)) return false; - try { - // Same-origin frames (the message body) are readable and don't count: - // a message clicked into and left would keep the session alive. - return element.contentDocument === null; - } catch { - return true; - } -} /** Policy subjects per client and connection round; a failed or empty answer is asked again. */ const subjects = new WeakMap }>(); @@ -228,9 +213,6 @@ export function SessionTimeoutGuard() { const onVisibility = () => { if (document.visibilityState === "visible") timerRef.current?.check(); }; - const frameCheck = setInterval(() => { - if (crossOriginFrameHasFocus()) onActivity(); - }, FRAME_FOCUS_CHECK_MS); 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); @@ -239,7 +221,6 @@ export function SessionTimeoutGuard() { for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true }); window.removeEventListener("focus", onActivity); document.removeEventListener("visibilitychange", onVisibility); - clearInterval(frameCheck); channel.close(); channelRef.current = null; };