1
0
Files
breadcrumb-the-shire/web/js/components/app-session-warning.js
fs 04995e3712 feat(session): preserve intended URL across session timeout and add expiry warning dialog
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>
2026-03-13 14:28:49 +01:00

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();
}