fix: add background keepalive ping to sync frontend idle timer with server

Prevents session timeout for users who stay active on a single page
(scrolling, reading) without making requests. Pings every 5 min only
when interaction was detected since last sync — no false keep-alive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 15:57:36 +01:00
parent c45c636614
commit e1dac186ac

View File

@@ -12,6 +12,7 @@
const WARNING_LEAD_SECONDS = 120;
const TICK_INTERVAL_MS = 1000;
const MIN_IDLE_TIMEOUT = 180; // Don't show warning if timeout < 3 min
const KEEPALIVE_INTERVAL_MS = 300_000; // Sync with server every 5 min (if active)
const INTERACTION_EVENTS = ['click', 'keydown', 'scroll', 'mousemove', 'touchstart'];
const INTERACTION_THROTTLE_MS = 30_000; // Only reset every 30s to limit overhead
@@ -235,6 +236,7 @@ const init = () => {
// ── User interaction tracking ──────────────────────────────
let lastInteractionReset = Date.now();
let hasInteractionSinceLastSync = false;
const onUserInteraction = () => {
const now = Date.now();
@@ -246,10 +248,47 @@ const init = () => {
// Only reset timer when dialog is NOT showing.
if (!isDialogOpen) {
lastActivity = now;
hasInteractionSinceLastSync = true;
scheduleWarning();
}
};
// ── Background keepalive ────────────────────────────────────
// Periodically syncs the server-side idle timer when user interaction
// was detected since the last sync. Without this, a user who stays on
// a single page (scrolling, reading, moving the mouse) would have
// their server-side timer expire even though the frontend shows "active".
const keepaliveTimerId = setInterval(async () => {
if (!hasInteractionSinceLastSync || isDialogOpen) {
return;
}
hasInteractionSinceLastSync = false;
try {
const body = new URLSearchParams({
[config.csrfKey]: config.csrfToken,
});
const response = await fetch(config.pingUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body,
});
if (!response.ok) {
return;
}
const data = await response.json();
if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) {
config.idleSeconds = data.idle_timeout_seconds;
}
} catch {
// Network error — silently ignore, warning dialog will handle expiry.
}
}, KEEPALIVE_INTERVAL_MS);
// ── Focus trap (keyboard) ──────────────────────────────────
const onKeyDown = (event) => {