fix: stop counting focus in the office editor or a plugin frame as activity

This commit is contained in:
Linus Rath
2026-10-28 04:18:24 +01:00
parent ecbbe137c4
commit 629e96b6bd
2 changed files with 22 additions and 39 deletions
@@ -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();
}
});
+4 -23
View File
@@ -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<IJMAPClient, { revision: number; subject: Promise<PolicySubject | null> }>();
@@ -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;
};