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>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -1,51 +1,67 @@
/**
* 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.
*/
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
/**
* 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 toPositiveInt = (value, fallback = 0) => {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
const pingUrl = root.dataset.sessionPingUrl || '';
const logoutUrl = root.dataset.sessionLogoutUrl || '';
const csrfKey = root.dataset.csrfKey || '';
const csrfToken = root.dataset.csrfToken || '';
if (!pingUrl || !logoutUrl || !csrfKey || !csrfToken) {
return null;
}
return { idleSeconds, pingUrl, logoutUrl, csrfKey, csrfToken };
return parsed;
};
/**
* Resolve the session warning dialog and its elements.
* @returns {object|null}
*/
const resolveDialog = () => {
const dialog = document.querySelector('[data-app-session-warning-dialog]');
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;
}
@@ -63,11 +79,6 @@ const resolveDialog = () => {
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';
@@ -77,30 +88,40 @@ const formatCountdown = (seconds) => {
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
/**
* Initialize the session warning system.
*/
const init = () => {
const config = readConfig();
export function initSessionWarning(root = document, runtimeConfig = {}) {
const host = resolveHost(root);
const config = resolveConfig(runtimeConfig);
if (!config) {
return;
return { destroy: () => {} };
}
const elements = resolveDialog();
const elements = resolveDialog(host, config.selector);
if (!elements) {
return;
warnOnce('UI_EL_MISSING', `Missing session warning dialog: ${config.selector}`, {
module: 'session-warning',
});
return { destroy: () => {} };
}
const { dialog, messageEl, extendButton, logoutButton } = elements;
const messageTemplate = messageEl.dataset.template || '';
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 lastActivity = Date.now();
let warningTimerId = 0;
let countdownTimerId = 0;
let keepaliveTimerId = 0;
let remainingSeconds = 0;
let isDialogOpen = false;
// ── Countdown ──────────────────────────────────────────────
let lastInteractionReset = Date.now();
let hasInteractionSinceLastSync = false;
const updateCountdownDisplay = () => {
const text = messageTemplate
@@ -110,10 +131,19 @@ const init = () => {
};
const stopCountdown = () => {
if (countdownTimerId) {
clearInterval(countdownTimerId);
countdownTimerId = 0;
if (!countdownTimerId) {
return;
}
clearInterval(countdownTimerId);
countdownTimerId = 0;
};
const clearWarningTimer = () => {
if (!warningTimerId) {
return;
}
clearTimeout(warningTimerId);
warningTimerId = 0;
};
const closeDialog = () => {
@@ -141,29 +171,11 @@ const init = () => {
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;
@@ -184,7 +196,16 @@ const init = () => {
extendButton.focus();
};
// ── Extend session (AJAX ping) ─────────────────────────────
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;
@@ -203,63 +224,43 @@ const init = () => {
});
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();
let hasInteractionSinceLastSync = false;
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;
hasInteractionSinceLastSync = true;
scheduleWarning();
if (isDialogOpen) {
return;
}
hasInteractionSinceLastSync = true;
scheduleWarning();
};
// ── Background keepalive ────────────────────────────────────
// Periodically syncs the server-side idle timer when user interaction
// was detected since the last sync. Without this, a user who stays on
// a single page (scrolling, reading, moving the mouse) would have
// their server-side timer expire even though the frontend shows "active".
const keepaliveTimerId = setInterval(async () => {
const onKeepalive = async () => {
if (!hasInteractionSinceLastSync || isDialogOpen) {
return;
}
@@ -272,7 +273,9 @@ const init = () => {
const response = await fetch(config.pingUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body,
});
@@ -285,15 +288,12 @@ const init = () => {
config.idleSeconds = data.idle_timeout_seconds;
}
} catch {
// Network error — silently ignore, warning dialog will handle expiry.
// Network error — warning dialog still handles timeout/expiry.
}
}, KEEPALIVE_INTERVAL_MS);
// ── Focus trap (keyboard) ──────────────────────────────────
};
const onKeyDown = (event) => {
if (event.key === 'Escape') {
// Prevent default ESC close — user must choose extend or logout.
event.preventDefault();
return;
}
@@ -302,9 +302,7 @@ const init = () => {
return;
}
const focusable = [extendButton, logoutButton].filter(
(el) => !el.disabled && !el.hidden
);
const focusable = [extendButton, logoutButton].filter((element) => !element.disabled && !element.hidden);
if (!focusable.length) {
event.preventDefault();
return;
@@ -319,52 +317,60 @@ const init = () => {
event.preventDefault();
last.focus();
}
} else if (active === last) {
return;
}
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();
bind(extendButton, 'click', (event) => {
event.preventDefault();
extendSession();
});
logoutButton.addEventListener('click', (e) => {
e.preventDefault();
bind(logoutButton, 'click', (event) => {
event.preventDefault();
doLogout();
});
dialog.addEventListener('keydown', onKeyDown);
dialog.addEventListener('click', onBackdropClick);
dialog.addEventListener('cancel', onCancel);
bind(dialog, 'keydown', onKeyDown);
bind(dialog, 'click', onBackdropClick);
bind(dialog, 'cancel', onCancel);
for (const eventName of INTERACTION_EVENTS) {
document.addEventListener(eventName, onUserInteraction, { passive: true });
bind(document, eventName, onUserInteraction, { passive: true });
}
// ── Start ──────────────────────────────────────────────────
keepaliveTimerId = setInterval(onKeepalive, KEEPALIVE_INTERVAL_MS);
scheduleWarning();
};
// Auto-init when module loads.
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
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;
}