1
0
Files
breadcrumb-the-shire/web/js/components/app-session-warning.js
fs c7b8fd516a feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support
Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:19:56 +01:00

377 lines
9.5 KiB
JavaScript

/**
* Session expiry warning dialog.
*/
import { warnOnce, resolveHost } from '../core/app-dom.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 body = new URLSearchParams({
[config.csrfKey]: config.csrfToken,
});
const response = await fetch(config.pingUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body,
});
if (!response.ok) {
window.location.reload();
return;
}
const data = await response.json();
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 body = new URLSearchParams({
[config.csrfKey]: config.csrfToken,
});
const response = await fetch(config.pingUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body,
});
if (!response.ok) {
return;
}
const data = await response.json();
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;
}