From fb458fe053ee6c4232e24251e353ea594a1d65ed Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Thu, 18 Jun 2026 12:42:28 -0400 Subject: [PATCH 01/13] Update timeout-related variables to use Number for better type safety and introduce safe storage access methods. Enhance session renewal logic and improve error handling for BroadcastChannel creation. This ensures more robust session management and clearer code structure. --- resources/js/bootstrap.js | 7 +- resources/js/common/sessionSync.js | 185 ++++++++++++++++++----- resources/js/components/Session.vue | 23 +-- resources/js/next/config/processmaker.js | 3 +- resources/js/next/config/session.js | 7 +- 5 files changed, 159 insertions(+), 66 deletions(-) diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index 8df87873b1..dc8c1efabe 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -353,10 +353,11 @@ if (window.Processmaker && window.Processmaker.broadcasting) { if (userID) { const timeoutScript = document.head.querySelector("meta[name=\"timeout-worker\"]")?.content; - const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content)); - const warnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); + const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content; + const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content); + const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; - const accountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1; + const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1; const sessionSyncState = initSessionSync({ userId: userID.content, diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 648a6f257b..cdd3b9ef9c 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -17,18 +17,39 @@ export const initSessionSync = ({ return null; } - const sessionChannelName = "pm-session-sync"; - const sessionLeaderKey = "pm:session:leader"; - const sessionStateKey = "pm:session:state"; - const sessionWarningKey = "pm:session:warning"; + const safeStorageGetItem = (key) => { + try { + return localStorage.getItem(key); + } catch (error) { + return null; + } + }; + + const safeTimeoutLength = Number(accountTimeoutLength); + const safeTimeoutWarnSeconds = Number(accountTimeoutWarnSeconds); + const safeTimeoutEnabled = Number(accountTimeoutEnabled); + const normalizedTimeoutLength = Number.isFinite(safeTimeoutLength) && safeTimeoutLength > 0 + ? safeTimeoutLength + : 0; + const normalizedTimeoutWarnSeconds = Number.isFinite(safeTimeoutWarnSeconds) && safeTimeoutWarnSeconds > 0 + ? safeTimeoutWarnSeconds + : 0; + const normalizedTimeoutEnabled = Number.isFinite(safeTimeoutEnabled) ? safeTimeoutEnabled : 1; + const sessionOrigin = window.location?.origin || "unknown-origin"; + const sessionScope = encodeURIComponent(`${sessionOrigin}:${userId}`); + const sessionKeyPrefix = `pm:session:${sessionScope}`; + const sessionChannelName = `${sessionKeyPrefix}:sync`; + const sessionLeaderKey = `${sessionKeyPrefix}:leader`; + const sessionStateKey = `${sessionKeyPrefix}:state`; + const sessionWarningKey = `${sessionKeyPrefix}:warning`; // Track keep-alive progress across tabs. - const sessionRenewingKey = "pm:session:renewing"; - const sessionSuppressKey = "pm:session:suppress-warning"; - const sessionMessageKey = "pm:session:message"; + const sessionRenewingKey = `${sessionKeyPrefix}:renewing`; + const sessionSuppressKey = `${sessionKeyPrefix}:suppress-warning`; + const sessionMessageKey = `${sessionKeyPrefix}:message`; const sessionTabId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const leaderHeartbeatMs = 4000; const leaderTtlMs = 8000; - const sessionDebugEnabled = localStorage.getItem("pm:session:debug") === "1"; + const sessionDebugEnabled = safeStorageGetItem("pm:session:debug") === "1"; const sessionDebugLog = (...args) => { if (sessionDebugEnabled && !isProd) { console.info("[SessionSync]", `[tab:${sessionTabId}]`, ...args); @@ -36,15 +57,24 @@ export const initSessionSync = ({ }; sessionDebugLog("worker:init", { timeoutScript }); - const AccountTimeoutWorker = new Worker(timeoutScript); - sessionDebugLog("worker:created"); + let AccountTimeoutWorker = null; + if (timeoutScript && normalizedTimeoutEnabled && normalizedTimeoutLength > 0) { + try { + AccountTimeoutWorker = new Worker(timeoutScript); + sessionDebugLog("worker:created"); + } catch (error) { + sessionDebugLog("worker:create-failed", error); + } + } else { + sessionDebugLog("worker:disabled", { timeoutScript, normalizedTimeoutEnabled, normalizedTimeoutLength }); + } const resolveSessionModal = () => (typeof getSessionModal === "function" ? getSessionModal() : null); const resolveCloseSessionModal = () => (typeof getCloseSessionModal === "function" ? getCloseSessionModal() : null); const readStorageJson = (key) => { try { - const raw = localStorage.getItem(key); + const raw = safeStorageGetItem(key); return raw ? JSON.parse(raw) : null; } catch (error) { return null; @@ -68,7 +98,7 @@ export const initSessionSync = ({ }; let sessionState = { - timeout: accountTimeoutLength, + timeout: normalizedTimeoutLength, startedAt: Date.now(), }; @@ -94,8 +124,13 @@ export const initSessionSync = ({ refreshSessionStateFromStorage(); const setSessionState = (timeoutMinutes) => { + const normalizedTimeout = Number(timeoutMinutes); + if (!Number.isFinite(normalizedTimeout) || normalizedTimeout <= 0) { + sessionDebugLog("session-state:invalid-timeout", { timeoutMinutes }); + return; + } sessionState = { - timeout: timeoutMinutes, + timeout: normalizedTimeout, startedAt: Date.now(), }; writeStorageJson(sessionStateKey, sessionState); @@ -180,7 +215,14 @@ export const initSessionSync = ({ writeStorageJson(sessionSuppressKey, suppressWarningState); }; - const sessionChannel = "BroadcastChannel" in window ? new BroadcastChannel(sessionChannelName) : null; + let sessionChannel = null; + if ("BroadcastChannel" in window) { + try { + sessionChannel = new BroadcastChannel(sessionChannelName); + } catch (error) { + sessionDebugLog("broadcast-channel:create-failed", error); + } + } const recentMessageIds = new Map(); const recentMessageTtlMs = 5000; const maxRecentMessageIds = 100; @@ -258,11 +300,11 @@ export const initSessionSync = ({ }; const markActivity = (source) => { - setSessionState(accountTimeoutLength); + setSessionState(normalizedTimeoutLength); clearWarningState(); setSuppressWarning(2000); - broadcastSessionEvent("activity", { timeout: accountTimeoutLength, source }); - sessionDebugLog("activity", { source, timeout: accountTimeoutLength }); + broadcastSessionEvent("activity", { timeout: normalizedTimeoutLength, source }); + sessionDebugLog("activity", { source, timeout: normalizedTimeoutLength }); const closeSessionModal = resolveCloseSessionModal(); if (closeSessionModal) { closeSessionModal(); @@ -273,6 +315,22 @@ export const initSessionSync = ({ } }; + const renewSession = (timeoutMinutes = normalizedTimeoutLength) => { + const timeout = Number(timeoutMinutes) || normalizedTimeoutLength; + clearWarningState(); + setRenewingState(false); + setSuppressWarning(1000); + setSessionState(timeout); + broadcastSessionEvent("renewed", { timeout }); + const closeSessionModal = resolveCloseSessionModal(); + if (closeSessionModal) { + closeSessionModal(); + } + if (isLeader()) { + startTimeoutWorker(timeout); + } + }; + const getRemainingTimeout = (timeoutMinutes) => { const elapsedMinutes = (Date.now() - sessionState.startedAt) / 60000; const remaining = timeoutMinutes - elapsedMinutes; @@ -288,6 +346,10 @@ export const initSessionSync = ({ }; const startTimeoutWorker = (timeoutMinutes) => { + if (!AccountTimeoutWorker || !normalizedTimeoutEnabled || normalizedTimeoutLength <= 0) { + sessionDebugLog("worker:start:skip", { hasWorker: !!AccountTimeoutWorker, normalizedTimeoutEnabled, normalizedTimeoutLength }); + return; + } const remaining = getRemainingTimeout(timeoutMinutes); sessionDebugLog("worker:start", { timeoutMinutes, remaining }); if (remaining <= 0) { @@ -300,8 +362,8 @@ export const initSessionSync = ({ method: "start", data: { timeout: remaining, - warnSeconds: accountTimeoutWarnSeconds, - enabled: accountTimeoutEnabled, + warnSeconds: normalizedTimeoutWarnSeconds, + enabled: normalizedTimeoutEnabled, }, }); }; @@ -330,11 +392,15 @@ export const initSessionSync = ({ const sessionModal = resolveSessionModal(); // Guard for layouts that don't include the session modal. if (typeof sessionModal === "function") { + const warningMessage = [ + "

Your user session is expiring. If your session expires, all of your unsaved data will be lost.

", + "

Would you like to stay connected?

", + ].join(""); sessionModal( "Session Warning", - "

Your user session is expiring. If your session expires, all of your unsaved data will be lost.

Would you like to stay connected?

", + warningMessage, remainingTime, - accountTimeoutWarnSeconds, + normalizedTimeoutWarnSeconds, ); } }; @@ -373,7 +439,7 @@ export const initSessionSync = ({ } if (message.type === "renewed" || message.type === "started" || message.type === "activity") { - const timeout = Number(message.data?.timeout) || accountTimeoutLength; + const timeout = Number(message.data?.timeout) || normalizedTimeoutLength; clearWarningState(); setRenewingState(false); setSuppressWarning(1000); @@ -401,19 +467,28 @@ export const initSessionSync = ({ } }; + const handleBroadcastMessage = (event) => handleSessionMessage(event.data); if (sessionChannel) { - sessionChannel.onmessage = (event) => handleSessionMessage(event.data); + sessionChannel.onmessage = handleBroadcastMessage; } - window.addEventListener("storage", (event) => { + const handleStorageMessage = (event) => { if (event.key !== sessionMessageKey || !event.newValue) { return; } handleSessionMessage(readStorageJson(sessionMessageKey)); - }); + }; + window.addEventListener("storage", handleStorageMessage); + + const handleStoredTerminalSessionMessage = () => { + const message = readStorageJson(sessionMessageKey); + if (message?.type === "logout" || message?.type === "expired") { + handleSessionMessage(message); + } + }; - AccountTimeoutWorker.onmessage = (e) => { + const handleWorkerMessage = (e) => { if (!isLeader()) { return; } @@ -438,6 +513,9 @@ export const initSessionSync = ({ window.location = "/logout?timeout=true"; } }; + if (AccountTimeoutWorker) { + AccountTimeoutWorker.onmessage = handleWorkerMessage; + } let wasLeader = false; const updateLeadership = () => { @@ -468,9 +546,12 @@ export const initSessionSync = ({ if (leaderNow) { ensureWorkerRunning("leadership-change"); } else { + workerStarted = false; + if (AccountTimeoutWorker) { + AccountTimeoutWorker.postMessage({ method: "stop" }); + } const closeSessionModal = resolveCloseSessionModal(); if (closeSessionModal) { - workerStarted = false; closeSessionModal(); } } @@ -482,8 +563,9 @@ export const initSessionSync = ({ markActivity("load"); ensureWorkerRunning("load"); } - setInterval(updateLeadership, leaderHeartbeatMs); - window.addEventListener("visibilitychange", () => { + const leadershipInterval = setInterval(updateLeadership, leaderHeartbeatMs); + const handleVisibilityChange = () => { + handleStoredTerminalSessionMessage(); updateLeadership(); // Keep warning state in sync when switching tabs. refreshWarningStateFromStorage(); @@ -493,17 +575,20 @@ export const initSessionSync = ({ refreshSessionStateFromStorage(); startTimeoutWorker(sessionState.timeout); } - }); + }; + window.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", handleStoredTerminalSessionMessage); // Broadcast manual logout so all tabs close warning and redirect. - document.addEventListener("click", (event) => { - const logoutLink = event.target.closest('a[href="/logout"], a[href^="/logout?"]'); + const handleDocumentClick = (event) => { + const logoutLink = event.target.closest("a[href=\"/logout\"], a[href^=\"/logout?\"]"); if (!logoutLink) { return; } clearWarningState(); broadcastSessionEvent("logout"); - }); + }; + document.addEventListener("click", handleDocumentClick); const isSameDevice = (e) => { const localDeviceId = Vue.$cookies.get(e.device_variable); @@ -532,10 +617,14 @@ export const initSessionSync = ({ } }) .listen(".SessionStarted", (e) => { - const lifetime = parseInt(eval(e.lifetime)); + const lifetime = Number(e.lifetime); if (!isSameDevice(e)) { return; } + if (!Number.isFinite(lifetime) || lifetime <= 0) { + sessionDebugLog("event:session-started:invalid-lifetime", { lifetime: e.lifetime }); + return; + } sessionDebugLog("event:session-started", { lifetime }); setSessionState(lifetime); @@ -557,7 +646,8 @@ export const initSessionSync = ({ const newDeviceId = Vue.$cookies.get(e.device_variable); if (localDeviceId !== newDeviceId) { clearInterval(redirectLogoutinterval); - window.location.href = "/logout"; + // eslint-disable-next-line no-undef + globalThis.location.href = "/logout"; } }, 100); } @@ -574,14 +664,31 @@ export const initSessionSync = ({ } return { - AccountTimeoutLength: accountTimeoutLength, - AccountTimeoutWarnSeconds: accountTimeoutWarnSeconds, - AccountTimeoutWarnMinutes: accountTimeoutWarnSeconds / 60, - AccountTimeoutEnabled: accountTimeoutEnabled, + AccountTimeoutLength: normalizedTimeoutLength, + AccountTimeoutWarnSeconds: normalizedTimeoutWarnSeconds, + AccountTimeoutWarnMinutes: normalizedTimeoutWarnSeconds / 60, + AccountTimeoutEnabled: normalizedTimeoutEnabled, AccountTimeoutWorker, sessionSync: { broadcast: broadcastSessionEvent, + destroy() { + clearInterval(leadershipInterval); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("storage", handleStorageMessage); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("visibilitychange", handleVisibilityChange); + // eslint-disable-next-line no-undef + globalThis.removeEventListener("focus", handleStoredTerminalSessionMessage); + document.removeEventListener("click", handleDocumentClick); + if (sessionChannel) { + sessionChannel.close(); + } + if (AccountTimeoutWorker) { + AccountTimeoutWorker.terminate(); + } + }, isLeader, + renewSession, setSessionState, clearWarningState, setRenewingState, diff --git a/resources/js/components/Session.vue b/resources/js/components/Session.vue index 5f53629e44..ea3fb3822d 100644 --- a/resources/js/components/Session.vue +++ b/resources/js/components/Session.vue @@ -121,25 +121,8 @@ export default { this.disabled = false; this.setRenewingState(false); const timeout = window.ProcessMaker.AccountTimeoutLength; - if (window.ProcessMaker.sessionSync?.setSessionState) { - window.ProcessMaker.sessionSync.setSessionState(timeout); - } - if (window.ProcessMaker.sessionSync?.clearWarningState) { - window.ProcessMaker.sessionSync.clearWarningState(); - } - if (window.ProcessMaker.sessionSync?.broadcast) { - window.ProcessMaker.sessionSync.broadcast("renewed", { timeout }); - } - // If reponse is correct, the timer is started again. - if (window.ProcessMaker.sessionSync?.isLeader?.() && typeof window.ProcessMaker.AccountTimeoutWorker !== "undefined") { - window.ProcessMaker.AccountTimeoutWorker.postMessage({ - method: "start", - data: { - timeout, - warnSeconds: window.ProcessMaker.AccountTimeoutWarnSeconds, - enabled: window.ProcessMaker.AccountTimeoutEnabled, - }, - }); + if (ProcessMaker.sessionSync?.renewSession) { + ProcessMaker.sessionSync.renewSession(timeout); } this.onClose(); }) @@ -154,7 +137,7 @@ export default { } this.disabled = false; this.setRenewingState(false); - this.errors = error.response.data.errors; + this.errors = error?.response?.data?.errors || {}; }); }, setRenewingState(isRenewing) { diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index 348227533a..a551811f3b 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -75,10 +75,11 @@ export default () => { if (error.code && error.code === "ERR_CANCELED") { return Promise.reject(error); } + EventBus.$emit("api-client-error", error); if (error.response && error.response.status && error.response.status === 401) { // stop 401 error consuming endpoints with data-sources - const { url } = error.config; + const url = error.config?.url || ""; if (url.includes("/data_sources/")) { if (url.includes("requests/") || url.includes("/test")) { throw error; diff --git a/resources/js/next/config/session.js b/resources/js/next/config/session.js index c1d54fc23d..b6d507e2fa 100644 --- a/resources/js/next/config/session.js +++ b/resources/js/next/config/session.js @@ -17,10 +17,11 @@ export default () => { } // Backend provides minutes for lifetime and seconds for warnings. - const accountTimeoutLength = parseInt(eval(document.head.querySelector("meta[name=\"timeout-length\"]")?.content)); - const warnSeconds = parseInt(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); + const timeoutEnabledMeta = document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content; + const accountTimeoutLength = Number(document.head.querySelector("meta[name=\"timeout-length\"]")?.content); + const warnSeconds = Number(document.head.querySelector("meta[name=\"timeout-warn-seconds\"]")?.content); const accountTimeoutWarnSeconds = Number.isNaN(warnSeconds) ? 0 : warnSeconds; - const accountTimeoutEnabled = document.head.querySelector("meta[name=\"timeout-enabled\"]") ? parseInt(document.head.querySelector("meta[name=\"timeout-enabled\"]")?.content) : 1; + const accountTimeoutEnabled = timeoutEnabledMeta ? Number(timeoutEnabledMeta) : 1; const sessionSyncState = initSessionSync({ userId: user.id, From 229ddcfd0cc9f0b8db81ad352b0a3702d17967dd Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Fri, 19 Jun 2026 17:58:11 -0400 Subject: [PATCH 02/13] Enhance session synchronization by adding a refresh mechanism after user clicks "STAY CONNECTED". This ensures the UI/state remains in sync without blocking the leader worker start. Improved error handling for the reload process. --- resources/js/common/sessionSync.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index cdd3b9ef9c..6589b0bd46 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -329,6 +329,14 @@ export const initSessionSync = ({ if (isLeader()) { startTimeoutWorker(timeout); } + + // After user clicks "STAY CONNECTED", refresh to ensure UI/state is in sync. + // Best-effort: we don't block leader worker start. + try { + window.location.reload(); + } catch (e) { + console.error('renewSession error', e); + } }; const getRemainingTimeout = (timeoutMinutes) => { From 28e49ef93b6bfcf0e239f5a31008fddf20949663 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 23 Jun 2026 12:37:34 -0400 Subject: [PATCH 03/13] Implement keep-alive functionality in LoginController to extend session expiration and refresh Passport API cookie. Enhance error handling for session unavailability and return CSRF token in response. --- .../Http/Controllers/Auth/LoginController.php | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index f50eff5596..ccdffe7a09 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cookie; use Illuminate\Validation\ValidationException; +use Laravel\Passport\ApiTokenCookieFactory; use Laravel\Passport\Passport; use ProcessMaker\Events\Logout; use ProcessMaker\Http\Controllers\Controller; @@ -243,9 +244,29 @@ public function username() return 'username'; } - public function keepAlive() + public function keepAlive(Request $request) { - return response('', 204); + // Touch the Laravel session to extend its server-side expiration. + // Return the session CSRF token and refresh Passport's API cookie. + $token = ''; + try { + $request->session()->put('_pm_keep_alive', time()); + $token = (string) $request->session()->token(); + } catch (\Throwable $e) { + // If session is unavailable, return an empty token and let the frontend handle auth failure. + } + + $response = response()->json(['token' => $token]); + + if (Auth::check() && $token !== '') { + // Passport's CreateFreshApiToken middleware only refreshes this + // cookie on GET requests. keep-alive is POST, so refresh it here. + $response->withCookie( + app(ApiTokenCookieFactory::class)->make(Auth::id(), $token) + ); + } + + return $response; } protected function authenticated(Request $request, $user) From 2124f3a93a159cd4d731fc69b684d2fea3583217 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 23 Jun 2026 12:38:33 -0400 Subject: [PATCH 04/13] Add CSRF token management utility functions for axios requests This commit introduces a new file, csrfToken.js, which contains functions to manage CSRF tokens in the application. It includes methods to read the current CSRF token, apply it to axios requests, and attach a request interceptor to ensure the latest token is used for every request. This enhances security and ensures proper handling of CSRF tokens in client-side requests. --- resources/js/common/csrfToken.js | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 resources/js/common/csrfToken.js diff --git a/resources/js/common/csrfToken.js b/resources/js/common/csrfToken.js new file mode 100644 index 0000000000..d842208475 --- /dev/null +++ b/resources/js/common/csrfToken.js @@ -0,0 +1,75 @@ +/** + * Read the current CSRF token from memory or the meta tag. + */ +export const getCsrfToken = () => { + if (window.ProcessMaker?.__pmXsrfToken) { + return window.ProcessMaker.__pmXsrfToken; + } + + const meta = document.head?.querySelector('meta[name="csrf-token"]'); + return meta?.content || null; +}; + +const setRequestCsrfHeader = (config, token) => { + if (!token) { + return; + } + + // Per-request header overrides axios defaults (even when defaults are frozen). + // eslint-disable-next-line no-param-reassign + config.headers = config.headers || {}; + // eslint-disable-next-line no-param-reassign + config.headers["X-CSRF-TOKEN"] = token; + + if (config.headers.common) { + // eslint-disable-next-line no-param-reassign + config.headers.common["X-CSRF-TOKEN"] = token; + } +}; + +/** + * Apply CSRF token to all client-side sources axios may read from. + */ +export const applyCsrfToken = (token) => { + if (!token) { + return false; + } + + window.ProcessMaker = window.ProcessMaker || {}; + + window.ProcessMaker.__pmXsrfToken = token; + + const meta = document.head?.querySelector('meta[name="csrf-token"]'); + if (meta) { + meta.setAttribute("content", token); + } + + try { + if (window.ProcessMaker?.apiClient?.defaults?.headers?.common) { + window.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token; + } + } catch (e) { + // If defaults are readonly, the request interceptor will still set the header. + } + + return true; +}; + +/** + * Attach a request interceptor so every request uses the latest CSRF token. + * Register this BEFORE other interceptors so it runs LAST in the axios chain. + */ +export const attachCsrfRequestInterceptor = (apiClient) => { + if (!apiClient?.interceptors?.request) { + return; + } + + apiClient.interceptors.request.use((config) => { + const token = getCsrfToken(); + if (token) { + setRequestCsrfHeader(config, token); + } + + return config; + }); +}; From 122de0637c64e5b960b692d5b9ea4e52a3d2a22c Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 23 Jun 2026 12:39:28 -0400 Subject: [PATCH 05/13] This update retrieves the CSRF token from the keep-alive response and applies it to future requests, ensuring improved security and synchronization of session state. Emit an event when the token is updated for better state management. --- resources/js/components/Session.vue | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/resources/js/components/Session.vue b/resources/js/components/Session.vue index ea3fb3822d..6f6e7f83bc 100644 --- a/resources/js/components/Session.vue +++ b/resources/js/components/Session.vue @@ -117,9 +117,20 @@ export default { ProcessMaker.apiClient .post("/keep-alive", {}, { baseURL: "" }) - .then(() => { + .then((response) => { + const { token } = response.data || {}; + this.disabled = false; this.setRenewingState(false); + + if (token && ProcessMaker.applyCsrfToken) { + ProcessMaker.applyCsrfToken(token); + } + + if (token) { + this.$emit("xsrf-updated", { token }); + } + const timeout = window.ProcessMaker.AccountTimeoutLength; if (ProcessMaker.sessionSync?.renewSession) { ProcessMaker.sessionSync.renewSession(timeout); From 4f2b3b6309ac4c9fbafdc950948d577fcb01b8cc Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 23 Jun 2026 12:42:54 -0400 Subject: [PATCH 06/13] remove reload --- resources/js/common/sessionSync.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 6589b0bd46..cdd3b9ef9c 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -329,14 +329,6 @@ export const initSessionSync = ({ if (isLeader()) { startTimeoutWorker(timeout); } - - // After user clicks "STAY CONNECTED", refresh to ensure UI/state is in sync. - // Best-effort: we don't block leader worker start. - try { - window.location.reload(); - } catch (e) { - console.error('renewSession error', e); - } }; const getRemainingTimeout = (timeoutMinutes) => { From 12c5c2d4a62f657af0c1f695bebbd5a216910a37 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Tue, 23 Jun 2026 12:44:26 -0400 Subject: [PATCH 07/13] Refactor CSRF token management in bootstrap.js and processmaker.js This commit reorganizes the CSRF token handling by importing utility functions for applying the CSRF token and attaching the request interceptor. It ensures that the CSRF token is consistently applied to axios requests across the application, enhancing security and session management. The code structure is improved by removing redundant lines and consolidating CSRF-related logic. --- resources/js/bootstrap.js | 24 +++++++++++++++------ resources/js/next/config/processmaker.js | 27 ++++++++++++++++++------ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index dc8c1efabe..a968f9069c 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -1,8 +1,6 @@ import "bootstrap-vue/dist/bootstrap-vue.css"; import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue"; import * as bootstrap from "bootstrap"; -import TenantAwareEcho from "./common/TenantAwareEcho"; -import { initSessionSync } from "./common/sessionSync"; import Router from "vue-router"; import ScreenBuilder, { initializeScreenCache } from "@processmaker/screen-builder"; import * as VueDeepSet from "vue-deepset"; @@ -20,6 +18,13 @@ import MonacoEditor from "vue-monaco"; import Vue from "vue"; import * as vue from "vue"; import VueCookies from "vue-cookies"; +import { initSessionSync } from "./common/sessionSync"; +import { + applyCsrfToken, + attachCsrfRequestInterceptor, + getCsrfToken, +} from "./common/csrfToken"; +import TenantAwareEcho from "./common/TenantAwareEcho"; import GlobalStore from "./globalStore"; import Pagination from "./components/common/Pagination"; import ScreenSelect from "./processes/modeler/components/inspector/ScreenSelect.vue"; @@ -242,19 +247,26 @@ window.ProcessMaker.i18nPromise.then(() => { translationsLoaded = true; }); */ window.ProcessMaker.apiClient = require("axios"); +window.ProcessMaker.apiClient.defaults.withCredentials = true; + window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; +const token = document.head.querySelector("meta[name=\"csrf-token\"]"); +const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; + +window.ProcessMaker.applyCsrfToken = applyCsrfToken; +window.ProcessMaker.getCsrfToken = getCsrfToken; +// Attach CSRF interceptor before other interceptors so it runs last in the axios chain. +attachCsrfRequestInterceptor(window.ProcessMaker.apiClient); + /** * Next we will register the CSRF Token as a common header with Axios so that * all outgoing HTTP requests automatically have it attached. This is just * a simple convenience so we don't have to attach every token manually. */ -const token = document.head.querySelector("meta[name=\"csrf-token\"]"); -const isProd = document.head.querySelector("meta[name=\"is-prod\"]")?.content === "true"; - if (token) { - window.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token.content; + applyCsrfToken(token.content, "page-load"); } else { console.error("CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token"); } diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index a551811f3b..314a95a4c4 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -1,5 +1,10 @@ import axios from "axios"; import { setGlobalPMVariables, getGlobalPMVariable } from "../globalVariables"; +import { + applyCsrfToken, + attachCsrfRequestInterceptor, + getCsrfToken, +} from "../../common/csrfToken"; export default () => { const token = document.head.querySelector("meta[name=\"csrf-token\"]"); @@ -21,10 +26,17 @@ export default () => { const apiClient = axios; + // Laravel web sessions / CSRF with cookie requires axios to send cookies. + apiClient.defaults.withCredentials = true; + apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; apiClient.defaults.baseURL = apiVersionConfig[0].baseURL; + setGlobalPMVariables({ + apiClient, + }); + apiClient.interceptors.request.use((config) => { if (typeof config.url !== "string" || !config.url) { throw new Error("Invalid URL in the request configuration"); @@ -43,6 +55,8 @@ export default () => { return config; }); + attachCsrfRequestInterceptor(apiClient); + // flags print forms apiClient.requestCount = 0; apiClient.requestCountFlag = false; @@ -66,7 +80,7 @@ export default () => { apiClient.requestCount -= 1; } return response; - }, (error) => { + }, async (error) => { // Set in your .catch to false to not show the alert inside window.ProcessMaker.apiClient if (!error?.response?.showAlert) { return Promise.reject(error); @@ -77,14 +91,16 @@ export default () => { } EventBus.$emit("api-client-error", error); + if (error.response && error.response.status && error.response.status === 401) { - // stop 401 error consuming endpoints with data-sources const url = error.config?.url || ""; + // stop 401 error consuming endpoints with data-sources if (url.includes("/data_sources/")) { if (url.includes("requests/") || url.includes("/test")) { throw error; } } + window.location = "/login"; } else { if (_.has(error, "config.url") && !error.config.url.match("/debug")) { @@ -108,7 +124,7 @@ export default () => { */ if (token) { - apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token.content; + applyCsrfToken(token.content, "page-load"); } else { console.error("CSRF token not found: https://laravel.com/docs/csrf#csrf-x-csrf-token"); } @@ -136,7 +152,6 @@ export default () => { } }); - setGlobalPMVariables({ - apiClient, - }); + window.ProcessMaker.applyCsrfToken = applyCsrfToken; + window.ProcessMaker.getCsrfToken = getCsrfToken; }; From 91ac0c1ae7313b190598141fd7675ba0a197d8f4 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Wed, 24 Jun 2026 12:06:33 -0400 Subject: [PATCH 08/13] fix test --- resources/js/common/sessionSync.js | 4 +++- tests/Feature/PermissionCacheInvalidationTest.php | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index cdd3b9ef9c..a0af52fb6d 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -558,9 +558,11 @@ export const initSessionSync = ({ } }; + if (document.visibilityState === "visible") { + markActivity("load"); + } updateLeadership(); if (isLeader()) { - markActivity("load"); ensureWorkerRunning("load"); } const leadershipInterval = setInterval(updateLeadership, leaderHeartbeatMs); diff --git a/tests/Feature/PermissionCacheInvalidationTest.php b/tests/Feature/PermissionCacheInvalidationTest.php index 49766f5ffd..f6fd511a5d 100644 --- a/tests/Feature/PermissionCacheInvalidationTest.php +++ b/tests/Feature/PermissionCacheInvalidationTest.php @@ -148,7 +148,7 @@ public function test_group_permission_update_does_not_logout_redis_backed_sessio $this->actingAs($this->user, 'web') ->post(route('keep-alive')) - ->assertNoContent(); + ->assertStatus(200); Passport::actingAs($this->user); @@ -157,7 +157,7 @@ public function test_group_permission_update_does_not_logout_redis_backed_sessio 'permission_names' => ['redis-group-permission-updated'], ])->assertNoContent(); - $this->post(route('keep-alive'))->assertNoContent(); + $this->post(route('keep-alive'))->assertStatus(200); $this->assertAuthenticatedAs($this->user, 'web'); $freshPermissions = $this->permissionService->getUserPermissions($affectedUser->id); From c16d293d206cec249e28662e03ed39cbb804d3e0 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Wed, 24 Jun 2026 12:19:43 -0400 Subject: [PATCH 09/13] Enhance Session component styling and z-index management --- resources/js/components/Session.vue | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/resources/js/components/Session.vue b/resources/js/components/Session.vue index 6f6e7f83bc..66d735177b 100644 --- a/resources/js/components/Session.vue +++ b/resources/js/components/Session.vue @@ -3,6 +3,8 @@ id="sessionModal" ref="sessionModal" :title="title" + modal-class="session-timeout-modal" + backdrop-class="session-timeout-backdrop" footer-class="pm-modal-footer" no-close-on-backdrop centered @@ -190,13 +192,12 @@ export default { }; - From 2eca5d61c31f3fc5496bcfcc36301cf4730a29d5 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Thu, 25 Jun 2026 12:51:17 -0400 Subject: [PATCH 10/13] Implement session renewal interceptor and enhance CSRF token management This commit introduces a session renewal interceptor that ensures the Laravel session and CSRF token are refreshed before API requests when the session is close to expiring. It also refactors the CSRF token management in `csrfToken.js` to utilize `globalThis` for better compatibility and adds debugging logs for session renewal processes. The changes improve session management and security across the application. --- resources/js/bootstrap.js | 3 + resources/js/common/csrfToken.js | 230 ++++++++++++++++++++++- resources/js/common/sessionSync.js | 45 +++++ resources/js/next/config/processmaker.js | 2 + 4 files changed, 272 insertions(+), 8 deletions(-) diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index a968f9069c..6f10bfad4a 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -22,6 +22,7 @@ import { initSessionSync } from "./common/sessionSync"; import { applyCsrfToken, attachCsrfRequestInterceptor, + attachSessionRenewalInterceptor, getCsrfToken, } from "./common/csrfToken"; import TenantAwareEcho from "./common/TenantAwareEcho"; @@ -296,6 +297,8 @@ window.ProcessMaker.apiClient.interceptors.request.use((config) => { return config; }); +attachSessionRenewalInterceptor(window.ProcessMaker.apiClient); + // Set the default API timeout let apiTimeout = 5000; if (window.Processmaker && window.Processmaker.apiTimeout !== undefined) { diff --git a/resources/js/common/csrfToken.js b/resources/js/common/csrfToken.js index d842208475..55c1fdc89d 100644 --- a/resources/js/common/csrfToken.js +++ b/resources/js/common/csrfToken.js @@ -2,11 +2,11 @@ * Read the current CSRF token from memory or the meta tag. */ export const getCsrfToken = () => { - if (window.ProcessMaker?.__pmXsrfToken) { - return window.ProcessMaker.__pmXsrfToken; + if (globalThis.ProcessMaker?.__pmXsrfToken) { + return globalThis.ProcessMaker.__pmXsrfToken; } - const meta = document.head?.querySelector('meta[name="csrf-token"]'); + const meta = globalThis.document?.head?.querySelector('meta[name="csrf-token"]'); return meta?.content || null; }; @@ -27,6 +27,31 @@ const setRequestCsrfHeader = (config, token) => { } }; +const safeStorageGetItem = (key) => { + try { + return globalThis.localStorage?.getItem(key); + } catch (error) { + return null; + } +}; + +const isSessionRenewalDebugEnabled = () => safeStorageGetItem("pm:session:debug") === "1"; + +const getRequestDebugData = (config) => { + const requestUrl = getRequestUrl(config); + + return { + method: config?.method || "get", + url: requestUrl ? `${requestUrl.pathname}${requestUrl.search}` : config?.url, + }; +}; + +const sessionRenewalDebugLog = (...args) => { + if (isSessionRenewalDebugEnabled()) { + console.info("[SessionRenewal]", ...args); + } +}; + /** * Apply CSRF token to all client-side sources axios may read from. */ @@ -35,26 +60,215 @@ export const applyCsrfToken = (token) => { return false; } - window.ProcessMaker = window.ProcessMaker || {}; + globalThis.ProcessMaker = globalThis.ProcessMaker || {}; - window.ProcessMaker.__pmXsrfToken = token; + globalThis.ProcessMaker.__pmXsrfToken = token; - const meta = document.head?.querySelector('meta[name="csrf-token"]'); + const meta = globalThis.document?.head?.querySelector('meta[name="csrf-token"]'); if (meta) { meta.setAttribute("content", token); } try { - if (window.ProcessMaker?.apiClient?.defaults?.headers?.common) { - window.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token; + if (globalThis.ProcessMaker?.apiClient?.defaults?.headers?.common) { + globalThis.ProcessMaker.apiClient.defaults.headers.common["X-CSRF-TOKEN"] = token; } } catch (e) { // If defaults are readonly, the request interceptor will still set the header. } + sessionRenewalDebugLog("csrf:applied", { + hasApiClientDefaults: !!globalThis.ProcessMaker?.apiClient?.defaults?.headers?.common, + hasMeta: !!meta, + }); + return true; }; +const getRequestUrl = (config) => { + if (typeof config?.url !== "string" || !config.url) { + return null; + } + + try { + const currentOrigin = globalThis.location?.origin; + const baseURL = config.baseURL + ? new URL(config.baseURL, currentOrigin).href + : currentOrigin; + + return new URL(config.url, baseURL); + } catch (error) { + return null; + } +}; + +const getSessionRenewalSkipReason = (config) => { + if (config?.skipSessionRenewal || config?.__skipSessionRenewal) { + return "request-marked-skip"; + } + + const requestUrl = getRequestUrl(config); + if (!requestUrl) { + return "invalid-url"; + } + + const currentOrigin = globalThis.location?.origin; + if (currentOrigin && requestUrl.origin !== currentOrigin) { + return "external-origin"; + } + + const excludedPath = [ + "/keep-alive", + "/logout", + "/login", + "/debug", + ].find((path) => requestUrl.pathname === path || requestUrl.pathname.startsWith(`${path}/`)); + + return excludedPath ? `excluded-path:${excludedPath}` : null; +}; + +const getSessionRenewalStatus = (sessionSync) => { + if (typeof sessionSync?.getRequestRenewalStatus === "function") { + return sessionSync.getRequestRenewalStatus(); + } + + return { + remainingSeconds: typeof sessionSync?.getRemainingSeconds === "function" + ? sessionSync.getRemainingSeconds() + : null, + renewalThresholdSeconds: null, + shouldRenew: typeof sessionSync?.shouldRenewBeforeRequest === "function" + && sessionSync.shouldRenewBeforeRequest(), + }; +}; + +const getRemainingSeconds = (sessionSync) => getSessionRenewalStatus(sessionSync).remainingSeconds; + +const logSessionRenewalSkipped = (config, reason) => { + sessionRenewalDebugLog("request:skip-renewal", { + ...getRequestDebugData(config), + reason, + }); +}; + +const handleExpiredSession = () => { + sessionRenewalDebugLog("session:expired"); + + if (globalThis.ProcessMaker?.sessionSync?.clearWarningState) { + globalThis.ProcessMaker.sessionSync.clearWarningState(); + } + + if (globalThis.ProcessMaker?.sessionSync?.broadcast) { + globalThis.ProcessMaker.sessionSync.broadcast("expired"); + } + + if (globalThis.location) { + globalThis.location.href = "/logout"; + } +}; + +let pendingSessionRenewal = null; + +const renewSessionBeforeRequest = async (apiClient) => { + if (pendingSessionRenewal) { + sessionRenewalDebugLog("keep-alive:join-pending"); + return pendingSessionRenewal; + } + + const sessionSync = globalThis.ProcessMaker?.sessionSync; + const renewalStatus = getSessionRenewalStatus(sessionSync); + sessionRenewalDebugLog("keep-alive:start", { + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + + pendingSessionRenewal = apiClient + .post("/keep-alive", {}, { + baseURL: "", + skipSessionRenewal: true, + __skipSessionRenewal: true, + }) + .then((response) => { + const { token } = response.data || {}; + + if (token) { + applyCsrfToken(token); + } + + const timeout = globalThis.ProcessMaker?.AccountTimeoutLength; + if (sessionSync?.renewSession && timeout) { + sessionSync.renewSession(timeout); + } + + sessionRenewalDebugLog("keep-alive:success", { + hasToken: !!token, + timeout, + }); + + return response; + }) + .catch((error) => { + const status = error?.response?.status; + if (status === 401 || status === 419) { + sessionRenewalDebugLog("keep-alive:auth-failed", { status }); + handleExpiredSession(); + throw error; + } + + sessionRenewalDebugLog("keep-alive:failed-continue", { + status, + message: error?.message, + }); + return null; + }) + .finally(() => { + sessionRenewalDebugLog("keep-alive:finished"); + pendingSessionRenewal = null; + }); + + return pendingSessionRenewal; +}; + +/** + * Renew the Laravel session and CSRF token right before an API request when the + * known session lifetime is close to expiring. + */ +export const attachSessionRenewalInterceptor = (apiClient) => { + if (!apiClient?.interceptors?.request) { + return; + } + + return apiClient.interceptors.request.use(async (config) => { + const sessionSync = globalThis.ProcessMaker?.sessionSync; + const skipReason = getSessionRenewalSkipReason(config); + const renewalStatus = getSessionRenewalStatus(sessionSync); + + if (skipReason) { + logSessionRenewalSkipped(config, skipReason); + return config; + } + + if (!renewalStatus.shouldRenew) { + sessionRenewalDebugLog("request:continue-with-current-session", { + ...getRequestDebugData(config), + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + return config; + } + + sessionRenewalDebugLog("request:renew-before-send", { + ...getRequestDebugData(config), + remainingSeconds: renewalStatus.remainingSeconds, + renewalThresholdSeconds: renewalStatus.renewalThresholdSeconds, + }); + + await renewSessionBeforeRequest(apiClient); + + return config; + }); +}; + /** * Attach a request interceptor so every request uses the latest CSRF token. * Register this BEFORE other interceptors so it runs LAST in the axios chain. diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index a0af52fb6d..4126c4767f 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -337,6 +337,48 @@ export const initSessionSync = ({ return Math.max(0, remaining); }; + const getRemainingSeconds = () => { + refreshSessionStateFromStorage(); + return Math.ceil(getRemainingTimeout(sessionState.timeout) * 60); + }; + + const getRequestRenewalThresholdSeconds = (thresholdSeconds) => { + const configuredThreshold = Number(thresholdSeconds); + const timeoutSeconds = normalizedTimeoutLength * 60; + const fallbackThreshold = Math.min(120, Math.floor(timeoutSeconds * 0.25)); + const defaultThreshold = normalizedTimeoutWarnSeconds + ? normalizedTimeoutWarnSeconds * 2 + : fallbackThreshold; + const baseThreshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 + ? configuredThreshold + : defaultThreshold; + const maxThreshold = Math.max(0, Math.floor(timeoutSeconds * 0.5)); + + return Math.min(baseThreshold, maxThreshold); + }; + + const shouldRenewBeforeRequest = (thresholdSeconds) => { + if (!AccountTimeoutWorker || !normalizedTimeoutEnabled || normalizedTimeoutLength <= 0) { + return false; + } + + const remainingSeconds = getRemainingSeconds(); + const renewalThresholdSeconds = getRequestRenewalThresholdSeconds(thresholdSeconds); + + return remainingSeconds > 0 && remainingSeconds <= renewalThresholdSeconds; + }; + + const getRequestRenewalStatus = (thresholdSeconds) => { + const remainingSeconds = getRemainingSeconds(); + const renewalThresholdSeconds = getRequestRenewalThresholdSeconds(thresholdSeconds); + + return { + remainingSeconds, + renewalThresholdSeconds, + shouldRenew: remainingSeconds > 0 && remainingSeconds <= renewalThresholdSeconds, + }; + }; + const getRemainingWarningTime = () => { if (!warningState?.time || !warningState?.ts) { return 0; @@ -694,6 +736,9 @@ export const initSessionSync = ({ setSessionState, clearWarningState, setRenewingState, + getRemainingSeconds, + getRequestRenewalStatus, + shouldRenewBeforeRequest, }, }; }; diff --git a/resources/js/next/config/processmaker.js b/resources/js/next/config/processmaker.js index 314a95a4c4..abc541da0d 100644 --- a/resources/js/next/config/processmaker.js +++ b/resources/js/next/config/processmaker.js @@ -3,6 +3,7 @@ import { setGlobalPMVariables, getGlobalPMVariable } from "../globalVariables"; import { applyCsrfToken, attachCsrfRequestInterceptor, + attachSessionRenewalInterceptor, getCsrfToken, } from "../../common/csrfToken"; @@ -56,6 +57,7 @@ export default () => { }); attachCsrfRequestInterceptor(apiClient); + attachSessionRenewalInterceptor(apiClient); // flags print forms apiClient.requestCount = 0; From f763960ca2c2707eeeb1b3b2832a71b68ef6c779 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Thu, 25 Jun 2026 20:44:49 -0400 Subject: [PATCH 11/13] Enhance session synchronization by adding timeout worker management and improving threshold calculations --- resources/js/common/sessionSync.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 4126c4767f..4aba04bb66 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -327,6 +327,9 @@ export const initSessionSync = ({ closeSessionModal(); } if (isLeader()) { + if (AccountTimeoutWorker) { + AccountTimeoutWorker.postMessage({ method: "stop" }); + } startTimeoutWorker(timeout); } }; @@ -345,14 +348,11 @@ export const initSessionSync = ({ const getRequestRenewalThresholdSeconds = (thresholdSeconds) => { const configuredThreshold = Number(thresholdSeconds); const timeoutSeconds = normalizedTimeoutLength * 60; - const fallbackThreshold = Math.min(120, Math.floor(timeoutSeconds * 0.25)); - const defaultThreshold = normalizedTimeoutWarnSeconds - ? normalizedTimeoutWarnSeconds * 2 - : fallbackThreshold; + const defaultThreshold = Math.floor(timeoutSeconds * 0.5); const baseThreshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 ? configuredThreshold : defaultThreshold; - const maxThreshold = Math.max(0, Math.floor(timeoutSeconds * 0.5)); + const maxThreshold = Math.max(0, timeoutSeconds - 5); return Math.min(baseThreshold, maxThreshold); }; @@ -537,6 +537,17 @@ export const initSessionSync = ({ if (e.data.method === "countdown") { sessionDebugLog("worker:countdown", e.data.data); + refreshSessionStateFromStorage(); + const remainingSeconds = getRemainingTimeout(sessionState.timeout) * 60; + if (remainingSeconds > normalizedTimeoutWarnSeconds) { + sessionDebugLog("worker:countdown:stale", { remainingSeconds, sessionState }); + clearWarningState(); + const closeSessionModal = resolveCloseSessionModal(); + if (closeSessionModal) { + closeSessionModal(); + } + return; + } setWarningState(e.data.data.time); showWarningIfActive(); broadcastSessionEvent("warning", { time: e.data.data.time }); From d3770ea223dd6e41cc71ed04936bb3ae0b0dbe21 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Thu, 25 Jun 2026 21:27:46 -0400 Subject: [PATCH 12/13] Refactor Session component to use dialog for session timeout overlay, enhancing accessibility and styling. Implement body scroll locking and focus management for improved user experience during session interactions. --- resources/js/components/Session.vue | 235 ++++++++++++++++++++-------- 1 file changed, 170 insertions(+), 65 deletions(-) diff --git a/resources/js/components/Session.vue b/resources/js/components/Session.vue index 66d735177b..d1b169b17d 100644 --- a/resources/js/components/Session.vue +++ b/resources/js/components/Session.vue @@ -1,64 +1,70 @@ From 17f8d31ab2e8385202380260d1b9d47a6c384266 Mon Sep 17 00:00:00 2001 From: "Marco A. Nina Mena" Date: Fri, 26 Jun 2026 12:23:57 -0400 Subject: [PATCH 13/13] Update default session renewal threshold to 80% of timeout length for improved session synchronization accuracy --- resources/js/common/sessionSync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/common/sessionSync.js b/resources/js/common/sessionSync.js index 4aba04bb66..67e717bfc3 100644 --- a/resources/js/common/sessionSync.js +++ b/resources/js/common/sessionSync.js @@ -348,7 +348,7 @@ export const initSessionSync = ({ const getRequestRenewalThresholdSeconds = (thresholdSeconds) => { const configuredThreshold = Number(thresholdSeconds); const timeoutSeconds = normalizedTimeoutLength * 60; - const defaultThreshold = Math.floor(timeoutSeconds * 0.5); + const defaultThreshold = Math.floor(timeoutSeconds * 0.8); const baseThreshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 ? configuredThreshold : defaultThreshold;