diff --git a/backend/actions/status.js b/backend/actions/status.js index 43f1e5b2..820fdd5e 100644 --- a/backend/actions/status.js +++ b/backend/actions/status.js @@ -1,7 +1,22 @@ 'use strict'; const nodeEnv = process.env.NODE_ENV; +const DEFAULT_INACTIVITY_TIMEOUT_MINUTES = 15; + +function parseInactivityTimeoutMinutes() { + const raw = process.env.INACTIVITY_TIMEOUT_MINUTES; + if (raw == null || raw === '') { + return DEFAULT_INACTIVITY_TIMEOUT_MINUTES; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_INACTIVITY_TIMEOUT_MINUTES; + } + return parsed; +} + +const inactivityTimeoutMinutes = parseInactivityTimeoutMinutes(); module.exports = () => async function status() { - return { nodeEnv }; + return { nodeEnv, inactivityTimeoutMinutes }; }; diff --git a/frontend/src/_util/inactivity-timeout.js b/frontend/src/_util/inactivity-timeout.js new file mode 100644 index 00000000..2a7117ef --- /dev/null +++ b/frontend/src/_util/inactivity-timeout.js @@ -0,0 +1,110 @@ +'use strict'; + +const DEFAULT_INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000; +const LAST_ACTIVITY_STORAGE_KEY = 'studio:last-activity-at'; +const ACTIVITY_THROTTLE_MS = 1000; +const ACTIVITY_EVENTS = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'scroll', 'click']; + +function isProductionEnv(nodeEnv) { + return nodeEnv === 'prod' || nodeEnv === 'production'; +} + +function logout() { + window.localStorage.setItem('_mongooseStudioAccessToken', ''); + window.localStorage.removeItem(LAST_ACTIVITY_STORAGE_KEY); + window.location.reload(); +} + +let inactivityTimeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS; + +function hasTimedOut(lastActivityAt) { + return lastActivityAt > 0 && Date.now() - lastActivityAt >= inactivityTimeoutMs; +} + +let timeoutId = null; +let started = false; +let lastResetAt = 0; + +function recordActivity() { + window.localStorage.setItem(LAST_ACTIVITY_STORAGE_KEY, String(Date.now())); +} + +function resetTimeout() { + if (timeoutId) { + clearTimeout(timeoutId); + } + recordActivity(); + timeoutId = setTimeout(logout, inactivityTimeoutMs); +} + +function onActivity() { + const now = Date.now(); + if (now - lastResetAt < ACTIVITY_THROTTLE_MS) { + return; + } + lastResetAt = now; + resetTimeout(); +} + +function onVisibilityChange() { + if (document.visibilityState !== 'visible') { + return; + } + const lastActivityAt = Number(window.localStorage.getItem(LAST_ACTIVITY_STORAGE_KEY)); + if (hasTimedOut(lastActivityAt)) { + logout(); + } +} + +function stopInactivityTimeout() { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + for (const event of ACTIVITY_EVENTS) { + window.removeEventListener(event, onActivity, true); + } + document.removeEventListener('visibilitychange', onVisibilityChange); + started = false; + lastResetAt = 0; + inactivityTimeoutMs = DEFAULT_INACTIVITY_TIMEOUT_MS; +} + +function resolveInactivityTimeoutMs(timeoutMinutes) { + if (timeoutMinutes != null && Number.isFinite(timeoutMinutes) && timeoutMinutes > 0) { + return timeoutMinutes * 60 * 1000; + } + return DEFAULT_INACTIVITY_TIMEOUT_MS; +} + +function startInactivityTimeout(nodeEnv, { freshLogin = false, timeoutMinutes } = {}) { + if (!isProductionEnv(nodeEnv)) { + return; + } + if (started) { + return; + } + started = true; + inactivityTimeoutMs = resolveInactivityTimeoutMs(timeoutMinutes); + + if (!freshLogin) { + const lastActivityAt = Number(window.localStorage.getItem(LAST_ACTIVITY_STORAGE_KEY)); + if (hasTimedOut(lastActivityAt)) { + logout(); + return; + } + } + + for (const event of ACTIVITY_EVENTS) { + window.addEventListener(event, onActivity, { capture: true, passive: true }); + } + document.addEventListener('visibilitychange', onVisibilityChange); + + resetTimeout(); +} + +module.exports = { + startInactivityTimeout, + stopInactivityTimeout, + isProductionEnv +}; diff --git a/frontend/src/index.js b/frontend/src/index.js index 64d07f6f..823b4033 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -22,6 +22,7 @@ const { normalizeTrackedPath, withRecentPagesStorageLock } = require('./_util/recent-pages-history'); +const { startInactivityTimeout } = require('./_util/inactivity-timeout'); appendCSS(require('vue-toastification/dist/index.css')); const TRACKED_RECENT_PAGE_ROUTE_NAMES = new Set(['model', 'document', 'dashboard', 'chat']); @@ -214,6 +215,9 @@ app.component('app-component', { window.$router = this.$router; window.state = this; + let freshLogin = false; + let inactivityTimeoutMinutes = 15; + if (mothership.hasAPIKey) { const hash = window.location.hash.replace(/^#?\/?/, '') || ''; const hashQuery = hash.split('?')[1] || ''; @@ -241,11 +245,12 @@ app.component('app-component', { window.localStorage.setItem('_mongooseStudioAccessToken', accessToken._id); try { - const [{ nodeEnv }, capabilities] = await Promise.all([ + const [{ nodeEnv, inactivityTimeoutMinutes: timeoutMinutes }, capabilities] = await Promise.all([ api.status(), loadStudioCapabilities() ]); this.nodeEnv = nodeEnv; + inactivityTimeoutMinutes = timeoutMinutes; this.capabilities = capabilities; } catch (err) { this.authError = 'Error connecting to Mongoose Studio API: ' + err.response?.data?.message ?? err.message; @@ -256,6 +261,7 @@ app.component('app-component', { this.user = user; this.roles = roles; + freshLogin = true; setTimeout(() => { this.$router.replace(this.$router.currentRoute.value.path); @@ -266,12 +272,13 @@ app.component('app-component', { const { user, roles } = await mothership.me(); try { - const [{ nodeEnv }, { modelSchemaPaths }, capabilities] = await Promise.all([ + const [{ nodeEnv, inactivityTimeoutMinutes: timeoutMinutes }, { modelSchemaPaths }, capabilities] = await Promise.all([ api.status(), api.Model.listModels(), loadStudioCapabilities() ]); this.nodeEnv = nodeEnv; + inactivityTimeoutMinutes = timeoutMinutes; this.modelSchemaPaths = modelSchemaPaths; this.capabilities = capabilities; } catch (err) { @@ -286,12 +293,13 @@ app.component('app-component', { } } else { try { - const [{ nodeEnv }, { modelSchemaPaths }, capabilities] = await Promise.all([ + const [{ nodeEnv, inactivityTimeoutMinutes: timeoutMinutes }, { modelSchemaPaths }, capabilities] = await Promise.all([ api.status(), api.Model.listModels(), loadStudioCapabilities() ]); this.nodeEnv = nodeEnv; + inactivityTimeoutMinutes = timeoutMinutes; this.modelSchemaPaths = modelSchemaPaths; this.capabilities = capabilities; } catch (err) { @@ -300,6 +308,10 @@ app.component('app-component', { } this.status = 'loaded'; + + if (mothership.hasAPIKey && this.user) { + startInactivityTimeout(this.nodeEnv, { freshLogin, timeoutMinutes: inactivityTimeoutMinutes }); + } }, setup() { const user = Vue.ref(null);