/** * Session expiry warning dialog. * * Reads the idle timeout from the element's data-session-idle-seconds * attribute and shows a modal dialog ~2 minutes before the session expires. * The user can extend the session (POST /admin/session-ping/data) or log out. * * Timer resets on user interaction (click, keypress, scroll) to stay in sync * with the server-side idle timer that is refreshed on every page request. */ const WARNING_LEAD_SECONDS = 120; const TICK_INTERVAL_MS = 1000; const MIN_IDLE_TIMEOUT = 180; // Don't show warning if timeout < 3 min const INTERACTION_EVENTS = ['click', 'keydown', 'scroll', 'mousemove', 'touchstart']; const INTERACTION_THROTTLE_MS = 30_000; // Only reset every 30s to limit overhead /** * Read configuration from DOM. * @returns {{idleSeconds: number, pingUrl: string, logoutUrl: string, csrfKey: string, csrfToken: string}|null} */ const readConfig = () => { const root = document.documentElement; const idleSeconds = parseInt(root.dataset.sessionIdleSeconds || '', 10); if (!idleSeconds || idleSeconds < MIN_IDLE_TIMEOUT) { return null; } const pingUrl = root.dataset.sessionPingUrl || ''; const logoutUrl = root.dataset.sessionLogoutUrl || ''; const csrfKey = root.dataset.csrfKey || ''; const csrfToken = root.dataset.csrfToken || ''; if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) { return null; } return { idleSeconds, pingUrl, logoutUrl, csrfKey, csrfToken }; }; /** * Resolve the session warning dialog and its elements. * @returns {object|null} */ const resolveDialog = () => { const dialog = document.querySelector('[data-app-session-warning-dialog]'); 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 }; }; /** * Format remaining seconds into a human-readable countdown string. * @param {number} seconds * @returns {string} */ 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')}`; }; /** * Initialize the session warning system. */ const init = () => { const config = readConfig(); if (!config) { return; } const elements = resolveDialog(); if (!elements) { return; } const { dialog, messageEl, extendButton, logoutButton } = elements; const messageTemplate = messageEl.dataset.template || ''; let lastActivity = Date.now(); let warningTimerId = 0; let countdownTimerId = 0; let remainingSeconds = 0; let isDialogOpen = false; // ── Countdown ────────────────────────────────────────────── const updateCountdownDisplay = () => { const text = messageTemplate ? messageTemplate.replace('{countdown}', formatCountdown(remainingSeconds)) : formatCountdown(remainingSeconds); messageEl.textContent = text; }; const stopCountdown = () => { if (countdownTimerId) { clearInterval(countdownTimerId); countdownTimerId = 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(); // Session expired — reload triggers the server-side timeout flow. window.location.reload(); } }, TICK_INTERVAL_MS); }; // ── Warning timer ────────────────────────────────────────── const scheduleWarning = () => { if (warningTimerId) { clearTimeout(warningTimerId); } const delayMs = (config.idleSeconds - WARNING_LEAD_SECONDS) * 1000; if (delayMs <= 0) { return; } warningTimerId = setTimeout(() => { showWarningDialog(); }, delayMs); }; 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(); }; // ── Extend session (AJAX ping) ───────────────────────────── 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) { // Session already expired — reload. window.location.reload(); return; } const data = await response.json(); // Server may return an updated idle timeout. if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) { config.idleSeconds = data.idle_timeout_seconds; } lastActivity = Date.now(); closeDialog(); scheduleWarning(); } catch { // Network error — reload to let server handle it. window.location.reload(); } finally { extendButton.disabled = false; } }; // ── Logout ───────────────────────────────────────────────── const doLogout = () => { closeDialog(); window.location.href = config.logoutUrl; }; // ── User interaction tracking ────────────────────────────── let lastInteractionReset = Date.now(); const onUserInteraction = () => { const now = Date.now(); if ((now - lastInteractionReset) < INTERACTION_THROTTLE_MS) { return; } lastInteractionReset = now; // Only reset timer when dialog is NOT showing. if (!isDialogOpen) { lastActivity = now; scheduleWarning(); } }; // ── Focus trap (keyboard) ────────────────────────────────── const onKeyDown = (event) => { if (event.key === 'Escape') { // Prevent default ESC close — user must choose extend or logout. event.preventDefault(); return; } if (event.key !== 'Tab') { return; } const focusable = [extendButton, logoutButton].filter( (el) => !el.disabled && !el.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(); } } else if (active === last) { event.preventDefault(); first.focus(); } }; // Prevent closing via backdrop click const onBackdropClick = (event) => { if (event.target === dialog) { event.preventDefault(); } }; // Prevent native cancel (ESC) const onCancel = (event) => { event.preventDefault(); }; // ── Wire up events ───────────────────────────────────────── extendButton.addEventListener('click', (e) => { e.preventDefault(); extendSession(); }); logoutButton.addEventListener('click', (e) => { e.preventDefault(); doLogout(); }); dialog.addEventListener('keydown', onKeyDown); dialog.addEventListener('click', onBackdropClick); dialog.addEventListener('cancel', onCancel); for (const eventName of INTERACTION_EVENTS) { document.addEventListener(eventName, onUserInteraction, { passive: true }); } // ── Start ────────────────────────────────────────────────── scheduleWarning(); }; // Auto-init when module loads. if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }