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>
This commit is contained in:
331
web/js/components/app-session-warning.js
Normal file
331
web/js/components/app-session-warning.js
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user