Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion backend/actions/status.js
Original file line number Diff line number Diff line change
@@ -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 };
};
110 changes: 110 additions & 0 deletions frontend/src/_util/inactivity-timeout.js
Original file line number Diff line number Diff line change
@@ -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
};
18 changes: 15 additions & 3 deletions frontend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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] || '';
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
Expand Down
Loading