mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: stop counting focus in the office editor or a plugin frame as activity
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user