forked from fa/breadcrumb-the-shire
When a session expires, the user's current URL is now captured and restored after re-authentication (via remember-me cookie or manual login), instead of always redirecting to the dashboard. A JavaScript session warning dialog appears ~2 minutes before idle timeout, allowing users to extend their session with a single click. Includes open-redirect prevention via URL validation, Microsoft SSO callback support, and 33 unit tests. Refs: SESSION-REDIRECT-PRESERVE-001 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
9.0 KiB
JavaScript
332 lines
9.0 KiB
JavaScript
/**
|
|
* Session expiry warning dialog.
|
|
*
|
|
* Reads the idle timeout from the <html> 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.sessionCsrfKey || '';
|
|
const csrfToken = root.dataset.sessionCsrfToken || '';
|
|
|
|
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();
|
|
}
|