349 lines
9.0 KiB
JavaScript
349 lines
9.0 KiB
JavaScript
/**
|
|
* Session expiry warning dialog.
|
|
*/
|
|
import { warnOnce, resolveHost } from '../core/app-dom.js';
|
|
import { postForm } from '../core/app-http.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 data = await postForm(config.pingUrl, {
|
|
[config.csrfKey]: config.csrfToken,
|
|
});
|
|
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 data = await postForm(config.pingUrl, {
|
|
[config.csrfKey]: config.csrfToken,
|
|
});
|
|
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;
|
|
}
|