From c9cd01653fd75aa5e1da59e80e2f7532486ba6df Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 7 Aug 2026 18:42:40 -0700 Subject: [PATCH] feat(session): move the SPA session poll off the legacy CFM - Point the poll at the VIPER 2 endpoint via VITE_API_URL, dropping the CFM URL that passed the login id as an unauthenticated query parameter - Keep plain fetch rather than useFetch, which reports every failure through the global error store: that would banner a silent five minute poll and fire the auth handler while our dialog offers a log in - Carry over the Razor fixes, since this file had the same defects: reject non-OK responses, report a failed extend in a StatusBanner while offering both Refresh Session and Log in, reschedule after a failed poll rather than stopping for the life of the page, stand the warning down when the session is extended elsewhere, and render midnight as 12 AM rather than 0 AM --- VueApp/src/components/SessionTimeout.vue | 113 ++++++++++++----------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/VueApp/src/components/SessionTimeout.vue b/VueApp/src/components/SessionTimeout.vue index 27c1be085..f4f4c45e0 100644 --- a/VueApp/src/components/SessionTimeout.vue +++ b/VueApp/src/components/SessionTimeout.vue @@ -2,83 +2,84 @@ import { ref } from "vue" import { useUserStore } from "@/store/UserStore" import { getLoginUrl } from "@/composables/RequireLogin" +import StatusBanner from "@/components/StatusBanner.vue" const userStore = useUserStore() -//https://" + HttpHelper.HttpContext?.Request.Host.Value -const onDev = import.meta.env.VITE_ENVIRONMENT === "DEVELOPMENT" const viperHome = import.meta.env.VITE_VIPER_HOME +const sessionTimeoutUrl = `${import.meta.env.VITE_API_URL}sessionTimeout` const loginHref = getLoginUrl() -const sessionRefreshUrl = - (onDev ? "http://localhost/" : "/") + - "public/timeout/seconds_until_timeout_v2.cfm?id=" + - userStore.userInfo.loginId + - "&service=" + - (onDev ? "Viper2-dev" : "Viper2") const showSessionTimeoutWarning = ref(false) const sessionExpireTime = ref("") const sessionExpired = ref(false) let sessionTimeoutCheckEventId = 0 const sessionReloaded = ref(false) +const sessionExtendFailed = ref(false) +// Hour 0 is 12 AM, not 0 AM. +function formatExpireTime(sessionTimeoutDateTime: string) { + const d = new Date(sessionTimeoutDateTime) + return (d.getHours() % 12 || 12) + ":" + ("0" + d.getMinutes()).slice(-2) + (d.getHours() >= 12 ? " PM" : " AM") +} + +// Plain fetch rather than useFetch, deliberately: these are session lifecycle calls. useFetch +// pushes every failure into the global error store, which would raise a banner on each blip of a +// silent five minute poll, and would fire the auth handler at exactly the moment our own dialog +// is offering the user a log in. async function checkSessionTimeout() { - if ( - userStore.userInfo.loginId === undefined || - userStore.userInfo.loginId === null || - userStore.userInfo.loginId.length === 0 - ) { + if (!userStore.userInfo.loginId) { return //don't check the session if the user is not logged in } - //try to get the session timeout from an external application - try { - fetch(sessionRefreshUrl) - .then((r) => (r.status === 200 ? r.json() : r)) - .then((r) => { - let nextCheck = 300 - //show timeout warning if the session will time out in 5 minutes or less - if (r.secondsUntilTimeout !== undefined && r.secondsUntilTimeout < 300) { - showSessionTimeoutWarning.value = true - sessionExpired.value = r.secondsUntilTimeout < 15 //consider session timing out in 15 seconds to be timed out already - var d = new Date(r.sessionTimeoutDateTime) - sessionExpireTime.value = - (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + - ":" + - ("0" + d.getMinutes()).slice(-2) + - (d.getHours() >= 12 ? " PM" : " AM") - nextCheck = sessionExpired.value ? 0 : Math.max(r.secondsUntilTimeout - 15, 5) - } - if (nextCheck > 0) { - sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, nextCheck * 1000) - } - }) - } catch (e) { - void e - } -} -async function extendSession() { - fetch(viperHome + "RefreshSession") - .then((r) => (r.status === 200 ? r.json() : r)) + // Timeout so a request that hangs rather than fails still reaches the catch below. + fetch(sessionTimeoutUrl, { signal: AbortSignal.timeout(10000) }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error("Session check returned " + r.status)))) .then((r) => { - try { - clearTimeout(sessionTimeoutCheckEventId) - } catch (e) { - void e + let nextCheck = 300 + //show timeout warning if the session will time out in 5 minutes or less + // "<=" so an exact 300 still warns: otherwise the next poll lands at expiry. + if (r.secondsUntilTimeout !== undefined && r.secondsUntilTimeout <= 300) { + showSessionTimeoutWarning.value = true + sessionExpired.value = r.secondsUntilTimeout < 15 //consider session timing out in 15 seconds to be timed out already + sessionExpireTime.value = formatExpireTime(r.sessionTimeoutDateTime) + nextCheck = sessionExpired.value ? 0 : Math.max(r.secondsUntilTimeout - 15, 5) + } else if (r.secondsUntilTimeout !== undefined) { + // Extended elsewhere, in another tab or by an API call, so stand the warning down. + hideSessionTimeoutWarning() + } + if (nextCheck > 0) { + sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, nextCheck * 1000) } + }) + // Silent, but reschedule: one failed poll must not stop the checks for the life of the page. + // Retry quickly once the warning is up, since expiry is minutes away. + .catch(() => { + const retry = showSessionTimeoutWarning.value ? 15000 : 300000 + sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, retry) + }) +} - var d = new Date(r.sessionTimeoutDateTime) - sessionExpireTime.value = - (d.getHours() > 12 ? d.getHours() - 12 : d.getHours()) + - ":" + - ("0" + d.getMinutes()).slice(-2) + - (d.getHours() >= 12 ? " PM" : " AM") +async function extendSession() { + sessionExtendFailed.value = false + fetch(viperHome + "RefreshSession", { signal: AbortSignal.timeout(10000) }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error("RefreshSession returned " + r.status)))) + .then((r) => { + clearTimeout(sessionTimeoutCheckEventId) + sessionExpireTime.value = formatExpireTime(r.sessionTimeoutDateTime) sessionReloaded.value = true sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 5000) window.setTimeout(hideSessionTimeoutWarning, 1000) }) + // Leave the dialog up so the user can retry or log in, and say so: the session was not extended. + .catch(() => { + sessionExtendFailed.value = true + }) } + function hideSessionTimeoutWarning() { showSessionTimeoutWarning.value = false + sessionExpired.value = false sessionReloaded.value = false + sessionExtendFailed.value = false } sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 60000) @@ -111,7 +112,7 @@ sessionTimeoutCheckEventId = window.setTimeout(checkSessionTimeout, 60000) color="secondary" class="q-px-md" label="Log in" - v-if="sessionExpired" + v-if="sessionExpired || sessionExtendFailed" :href="loginHref" > + + Could not extend your session. Please try again. +