/** * Session expiry warning dialog. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; 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 const toPositiveInt = (value, fallback = 0) => { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } return parsed; }; const toTrimmed = (value) => String(value ?? '').trim(); const resolveConfig = (runtimeConfig = {}) => { const root = document.documentElement; const config = runtimeConfig && typeof runtimeConfig === 'object' && !Array.isArray(runtimeConfig) ? runtimeConfig : {}; const enabled = config.enabled !== false; if (!enabled) { return null; } const idleSeconds = toPositiveInt(config.idleSeconds ?? root.dataset.sessionIdleSeconds, 0); const pingUrl = toTrimmed(config.pingUrl ?? root.dataset.sessionPingUrl); const logoutUrl = toTrimmed(config.logoutUrl ?? root.dataset.sessionLogoutUrl); const csrfKey = toTrimmed(config.csrfKey ?? root.dataset.csrfKey); const csrfToken = toTrimmed(config.csrfToken ?? root.dataset.csrfToken); const selector = toTrimmed(config.selector || '[data-app-session-warning-dialog]') || '[data-app-session-warning-dialog]'; if (idleSeconds < MIN_IDLE_TIMEOUT) { return null; } if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) { warnOnce('UI_CONFIG_INVALID', 'Session warning config is incomplete', { module: 'session-warning', }); return null; } return { idleSeconds, pingUrl, logoutUrl, csrfKey, csrfToken, selector, }; }; const resolveDialog = (host, selector) => { const dialog = host instanceof HTMLDialogElement && host.matches(selector) ? host : host.querySelector(selector); if (!(dialog instanceof HTMLDialogElement)) { return null; } const messageEl = dialog.querySelector('[data-session-warning-message]'); const extendButton = dialog.querySelector('[data-session-warning-extend]'); const logoutButton = dialog.querySelector('[data-session-warning-logout]'); if (!(messageEl instanceof HTMLElement) || !(extendButton instanceof HTMLButtonElement) || !(logoutButton instanceof HTMLButtonElement)) { return null; } return { dialog, messageEl, extendButton, logoutButton }; }; const formatCountdown = (seconds) => { if (seconds <= 0) { return '0:00'; } const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; export function initSessionWarning(root = document, runtimeConfig = {}) { const host = resolveHost(root); const config = resolveConfig(runtimeConfig); if (!config) { return { destroy: () => {} }; } const elements = resolveDialog(host, config.selector); if (!elements) { warnOnce('UI_EL_MISSING', `Missing session warning dialog: ${config.selector}`, { module: 'session-warning', }); return { destroy: () => {} }; } const { dialog, messageEl, extendButton, logoutButton } = elements; if (dialog.dataset.sessionWarningBound === '1' && dialog._sessionWarningApi) { return dialog._sessionWarningApi; } const messageTemplate = messageEl.dataset.template || ''; const cleanupFns = []; const bind = (target, eventName, handler, options = undefined) => { target.addEventListener(eventName, handler, options); cleanupFns.push(() => target.removeEventListener(eventName, handler, options)); }; let warningTimerId = 0; let countdownTimerId = 0; let keepaliveTimerId = 0; let remainingSeconds = 0; let isDialogOpen = false; let lastInteractionReset = Date.now(); let hasInteractionSinceLastSync = false; const updateCountdownDisplay = () => { const text = messageTemplate ? messageTemplate.replace('{countdown}', formatCountdown(remainingSeconds)) : formatCountdown(remainingSeconds); messageEl.textContent = text; }; const stopCountdown = () => { if (!countdownTimerId) { return; } clearInterval(countdownTimerId); countdownTimerId = 0; }; const clearWarningTimer = () => { if (!warningTimerId) { return; } clearTimeout(warningTimerId); warningTimerId = 0; }; const closeDialog = () => { stopCountdown(); isDialogOpen = false; try { if (dialog.open) { dialog.close(); } } catch { // no-op } document.body.classList.remove('modal-is-open'); }; const startCountdown = () => { stopCountdown(); remainingSeconds = WARNING_LEAD_SECONDS; updateCountdownDisplay(); countdownTimerId = setInterval(() => { remainingSeconds -= 1; updateCountdownDisplay(); if (remainingSeconds <= 0) { stopCountdown(); closeDialog(); window.location.reload(); } }, TICK_INTERVAL_MS); }; const showWarningDialog = () => { if (isDialogOpen) { return; } isDialogOpen = true; startCountdown(); document.body.classList.add('modal-is-open'); try { dialog.showModal(); } catch { isDialogOpen = false; document.body.classList.remove('modal-is-open'); return; } extendButton.focus(); }; const scheduleWarning = () => { clearWarningTimer(); const delayMs = (config.idleSeconds - WARNING_LEAD_SECONDS) * 1000; if (delayMs <= 0) { return; } warningTimerId = setTimeout(() => { showWarningDialog(); }, delayMs); }; const extendSession = async () => { extendButton.disabled = true; 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) { window.location.reload(); return; } const data = await response.json(); if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) { config.idleSeconds = data.idle_timeout_seconds; } closeDialog(); scheduleWarning(); } catch { window.location.reload(); } finally { extendButton.disabled = false; } }; const doLogout = () => { closeDialog(); window.location.href = config.logoutUrl; }; const onUserInteraction = () => { const now = Date.now(); if ((now - lastInteractionReset) < INTERACTION_THROTTLE_MS) { return; } lastInteractionReset = now; if (isDialogOpen) { return; } hasInteractionSinceLastSync = true; scheduleWarning(); }; const onKeepalive = 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 — warning dialog still handles timeout/expiry. } }; const onKeyDown = (event) => { if (event.key === 'Escape') { event.preventDefault(); return; } if (event.key !== 'Tab') { return; } const focusable = [extendButton, logoutButton].filter((element) => !element.disabled && !element.hidden); if (!focusable.length) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey) { if (active === first || !dialog.contains(active)) { event.preventDefault(); last.focus(); } return; } if (active === last) { event.preventDefault(); first.focus(); } }; const onBackdropClick = (event) => { if (event.target === dialog) { event.preventDefault(); } }; const onCancel = (event) => { event.preventDefault(); }; bind(extendButton, 'click', (event) => { event.preventDefault(); extendSession(); }); bind(logoutButton, 'click', (event) => { event.preventDefault(); doLogout(); }); bind(dialog, 'keydown', onKeyDown); bind(dialog, 'click', onBackdropClick); bind(dialog, 'cancel', onCancel); for (const eventName of INTERACTION_EVENTS) { bind(document, eventName, onUserInteraction, { passive: true }); } keepaliveTimerId = setInterval(onKeepalive, KEEPALIVE_INTERVAL_MS); scheduleWarning(); const destroy = () => { cleanupFns.forEach((cleanup) => cleanup()); cleanupFns.length = 0; clearWarningTimer(); stopCountdown(); if (keepaliveTimerId) { clearInterval(keepaliveTimerId); keepaliveTimerId = 0; } closeDialog(); delete dialog.dataset.sessionWarningBound; delete dialog._sessionWarningApi; }; const api = { destroy }; dialog.dataset.sessionWarningBound = '1'; dialog._sessionWarningApi = api; return api; }