diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 6b58f89c5..b1088e695 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -6,6 +6,7 @@ // import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; import localStorage from '@store/local.storage'; +import moment from 'moment'; // ========================= // = Authorization Types = @@ -115,6 +116,23 @@ export const AUTH_TYPE = 'auth_type'; export const AUTH_LOGIN_GOTO_PATH = 'login_goto'; export const AUTH_LOGIN_GOTO_PATH_AUTH_TYPE = 'login_goto_auth_type'; +// ==================== +// = Auto logout = +// ==================== +export const autoLogoutConfig = { + INACTIVITY_TIMER_DEFAULT_MS: moment.duration(10, 'minutes').asMilliseconds(), + INACTIVITY_TIMER_STAFF_MS: moment.duration(10, 'minutes').asMilliseconds(), + INACTIVITY_TIMER_LICENSEE_MS: moment.duration(10, 'minutes').asMilliseconds(), + GRACE_PERIOD_MS: moment.duration(30, 'seconds').asMilliseconds(), + LOG: (message = '') => { + const isEnabled = false; // Helper logging for auto-logout testing + + if (isEnabled) { + console.log(`auto-logout: ${message}`); + } + }, +}; + // ==================== // = User Languages = // ==================== diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index 85b4c7f78..a082ac900 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -21,6 +21,7 @@ import { import { CompactType } from '@models/Compact/Compact.model'; import PageContainer from '@components/Page/PageContainer/PageContainer.vue'; import Modal from '@components/Modal/Modal.vue'; +import AutoLogout from '@components/AutoLogout/AutoLogout.vue'; import { StatsigUser } from '@statsig/js-client'; import moment from 'moment'; @@ -29,6 +30,7 @@ import moment from 'moment'; components: { PageContainer, Modal, + AutoLogout, }, emits: [ 'trigger-scroll-behavior' diff --git a/webroot/src/components/App/App.vue b/webroot/src/components/App/App.vue index 00b257d9d..bf957b87f 100644 --- a/webroot/src/components/App/App.vue +++ b/webroot/src/components/App/App.vue @@ -34,6 +34,7 @@ + diff --git a/webroot/src/components/AutoLogout/AutoLogout.less b/webroot/src/components/AutoLogout/AutoLogout.less new file mode 100644 index 000000000..2de79067d --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.less @@ -0,0 +1,66 @@ +// +// AutoLogout.less +// CompactConnect +// +// Created by InspiringApps on 11/13/2025. +// + +.auto-logout-modal { + color: @fontColor; + + :deep(.modal-container) { + width: 95%; + max-width: 60rem; + padding: 2rem; + + @media @tabletWidth { + padding: 4rem; + } + + .header-container { + justify-content: center; + margin-bottom: 1rem; + } + + .modal-content { + display: flex; + flex-direction: column; + align-items: center; + + .description-container { + text-align: center; + } + + .action-button-row { + display: flex; + flex-direction: column; + width: 100%; + margin-top: 4rem; + + @media @desktopWidth { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + } + + .action-button { + width: 100%; + margin-bottom: 1rem; + + @media @desktopWidth { + width: auto; + + &:not(:first-child) { + margin-left: 2.4rem; + } + } + + .input-button, + .input-submit { + width: 100%; + } + } + } + } + } +} diff --git a/webroot/src/components/AutoLogout/AutoLogout.spec.ts b/webroot/src/components/AutoLogout/AutoLogout.spec.ts new file mode 100644 index 000000000..3de19b1ec --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.spec.ts @@ -0,0 +1,19 @@ +// +// AutoLogout.spec.ts +// CompactConnect +// +// Created by InspiringApps on 11/13/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import AutoLogout from '@components/AutoLogout/AutoLogout.vue'; + +describe('AutoLogout component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(AutoLogout); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(AutoLogout).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/AutoLogout/AutoLogout.ts b/webroot/src/components/AutoLogout/AutoLogout.ts new file mode 100644 index 000000000..a2fa21481 --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.ts @@ -0,0 +1,195 @@ +// +// AutoLogout.ts +// CompactConnect +// +// Created by InspiringApps on 11/13/2025. +// + +import { + Component, + mixins, + Watch, + toNative +} from 'vue-facing-decorator'; +import { reactive, nextTick } from 'vue'; +import { autoLogoutConfig } from '@/app.config'; +import MixinForm from '@components/Forms/_mixins/form.mixin'; +import Modal from '@components/Modal/Modal.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import { FormInput } from '@/models/FormInput/FormInput.model'; + +@Component({ + name: 'AutoLogout', + components: { + Modal, + InputSubmit, + }, +}) +class AutoLogout extends mixins(MixinForm) { + // + // Data + // + gracePeriodTimerId: number | null = null; + gracePeriodExtendEnabled = true; + eventsController: AbortController | null = null; + eventDebounceMs = 1000; + activityResetEventTypes = [ + 'mousemove', + 'mousedown', + 'click', + 'keypress', + 'touchstart', + 'touchend', + 'touchmove', + 'onscroll', + 'wheel', + 'mousewheel', + ]; + + // + // Lifecycle + // + async created() { + if (this.userStore.isLoggedIn) { + this.initFormInputs(); + this.startAutoLogoutInactivityTimer(); + } + } + + async beforeUnmount() { + this.removeAutoLogoutEvents(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + // + // Methods + // + initFormInputs(): void { + this.formData = reactive({ + stayLoggedIn: new FormInput({ + isSubmitInput: true, + id: 'auto-logout-cancel-button', + }), + }); + } + + startAutoLogoutInactivityTimer(): void { + const { isLoggedIn, isAutoLogoutWarning } = this.userStore; + const abortController = new AbortController(); + const eventHandler = (event) => { + autoLogoutConfig.LOG(`event: ${event.type}`); + this.$store.dispatch('user/startAutoLogoutInactivityTimer'); + this.startAutoLogoutInactivityTimer(); + }; + const debouncedEventHandler = this.debounce(eventHandler, this.eventDebounceMs); + + this.removeAutoLogoutEvents(); + + if (isLoggedIn && !isAutoLogoutWarning) { + this.$store.dispatch('user/startAutoLogoutInactivityTimer'); + this.eventsController = abortController; + this.activityResetEventTypes.forEach((eventType) => { + document.addEventListener(eventType, debouncedEventHandler, { + capture: false, + once: true, + passive: true, + signal: abortController.signal, + }); + }); + autoLogoutConfig.LOG(`event listeners created`); + } + } + + startAutoLogoutGracePeriodTimer(): void { + const { isLoggedIn, isAutoLogoutWarning } = this.userStore; + + if (isLoggedIn && isAutoLogoutWarning) { + autoLogoutConfig.LOG(`grace period started`); + this.gracePeriodTimerId = window.setTimeout(() => { + autoLogoutConfig.LOG(`grace period logging out...`); + this.gracePeriodExtendEnabled = false; + this.$router.push({ name: 'Logout' }); + }, autoLogoutConfig.GRACE_PERIOD_MS); + } + } + + clearAutoLogoutGracePeriodTimer(): void { + const { gracePeriodTimerId } = this; + + if (gracePeriodTimerId) { + clearTimeout(gracePeriodTimerId); + this.gracePeriodTimerId = null; + } + } + + debounce(callback, delayMs): () => void { + let timeout; + + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => callback.apply(this, args), delayMs); + }; + } + + staySignedIn(): void { + this.clearAutoLogoutGracePeriodTimer(); + this.$store.dispatch('user/updateAutoLogoutWarning', false); + this.startAutoLogoutInactivityTimer(); + } + + focusTrapAutoLogoutModal(event: KeyboardEvent): void { + const firstTabIndex = document.getElementById('auto-logout-cancel-button'); + const lastTabIndex = document.getElementById('auto-logout-cancel-button'); + + if (event.shiftKey) { + // shift + tab to last input + if (document.activeElement === firstTabIndex) { + lastTabIndex?.focus(); + event.preventDefault(); + } + } else if (document.activeElement === lastTabIndex) { + // Tab to first input + firstTabIndex?.focus(); + event.preventDefault(); + } + } + + removeAutoLogoutEvents(): void { + const { eventsController } = this; + + if (eventsController) { + eventsController.abort(); + this.eventsController = null; + autoLogoutConfig.LOG(`event listeners removed`); + } + } + + // + // Watchers + // + @Watch('userStore.isLoggedIn') async handleLoginUpdate() { + if (!this.userStore.isLoggedIn) { + this.removeAutoLogoutEvents(); + } else { + this.startAutoLogoutInactivityTimer(); + } + } + + @Watch('userStore.isAutoLogoutWarning') async handleAutoLogoutWarning() { + if (this.userStore.isAutoLogoutWarning) { + this.startAutoLogoutGracePeriodTimer(); + await nextTick(); + document.getElementById('auto-logout-cancel-button')?.focus(); + } + } +} + +export default toNative(AutoLogout); + +// export default AutoLogout; diff --git a/webroot/src/components/AutoLogout/AutoLogout.vue b/webroot/src/components/AutoLogout/AutoLogout.vue new file mode 100644 index 000000000..f004019bd --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.vue @@ -0,0 +1,40 @@ + + + + + + + + {{ $t('account.autoLogoutWarningDescription') }} + + + + + + + + + + + diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index cf8eac659..062a0f9f0 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -991,7 +991,10 @@ "resetAccountErrorTitle": "Verification error", "resetAccountErrorSubtext": "If you are trying to reuse a link that was sent to you, be aware that the links are only valid for a short period of time and can only be used once.", "resetAccountActionLogin": "Go to login", - "resetAccountActionDashboard": "Go to dashboard" + "resetAccountActionDashboard": "Go to dashboard", + "autoLogoutWarningTitle": "Are you still there?", + "autoLogoutWarningDescription": "You’ve been inactive for a while. For your security, you’ll be signed out soon.", + "autoLogoutStaySignedIn": "Stay signed in" }, "recaptcha": { "googleDesc": "This site is protected by reCAPTCHA and the Google", diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index ddfcce1a4..9ac51dfb4 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -975,7 +975,10 @@ "resetAccountErrorTitle": "Error de verificación", "resetAccountErrorSubtext": "Si intenta reutilizar un enlace que le enviaron, tenga en cuenta que los enlaces solo son válidos por un corto período de tiempo y solo se pueden usar una vez.", "resetAccountActionLogin": "Ir a iniciar sesión", - "resetAccountActionDashboard": "Ir al panel de control" + "resetAccountActionDashboard": "Ir al panel de control", + "autoLogoutWarningTitle": "¿Estás ahí todavía?", + "autoLogoutWarningDescription": "Has estado inactivo durante un tiempo. Por tu seguridad, tu sesión se cerrará pronto.", + "autoLogoutStaySignedIn": "Mantente conectado" }, "privacyPolicy": { "title": "Política de Privacidad", diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 140867cdc..081e60dbc 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -11,7 +11,8 @@ import { authStorage, AuthTypes, tokens, - AUTH_TYPE + AUTH_TYPE, + autoLogoutConfig } from '@/app.config'; import localStorage from '@store/local.storage'; import { Compact } from '@models/Compact/Compact.model'; @@ -275,6 +276,49 @@ export default { clearTimeout(refreshTokenTimeoutId); commit(MutationTypes.SET_REFRESH_TIMEOUT_ID, null); }, + startAutoLogoutInactivityTimer: ({ dispatch, state }) => { + const { isLoggedIn, isAutoLogoutWarning } = state; + + dispatch('clearAutoLogoutTimeout'); + + if (isLoggedIn && !isAutoLogoutWarning) { + dispatch('setAutoLogoutTimeout'); + } + }, + setAutoLogoutTimeout: async ({ commit, dispatch, state }) => { + const { isLoggedInAsStaff, isLoggedInAsLicensee } = state; + let initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_DEFAULT_MS; // Default inactivity timer + + if (isLoggedInAsStaff) { + initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_STAFF_MS; // Inactivity timer for Staff + } else if (isLoggedInAsLicensee) { + initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_LICENSEE_MS; // Inactivity timer for Licensees + } + + const initiateAutoLogout = () => { + dispatch('clearAutoLogoutTimeout'); + dispatch('updateAutoLogoutWarning', true); + autoLogoutConfig.LOG(`auto logout warning: ${state.isAutoLogoutWarning}`); + }; + const timeoutId = setTimeout(initiateAutoLogout, initiateInMs); + + autoLogoutConfig.LOG(`timer started in store`); + + commit(MutationTypes.SET_LOGOUT_TIMEOUT_ID, timeoutId); + }, + clearAutoLogoutTimeout: ({ commit, state }) => { + const { autoLogoutTimeoutId } = state; + + if (autoLogoutTimeoutId) { + clearTimeout(autoLogoutTimeoutId); + autoLogoutConfig.LOG(`timer cleared in store`); + } + + commit(MutationTypes.SET_LOGOUT_TIMEOUT_ID, null); + }, + updateAutoLogoutWarning: ({ commit }, isWarning) => { + commit(MutationTypes.UPDATE_AUTO_LOGOUT_WARNING, isWarning); + }, clearSessionStores: ({ dispatch }) => { dispatch('resetStoreUser'); dispatch('license/resetStoreLicense', null, { root: true }); diff --git a/webroot/src/store/user/user.mutations.ts b/webroot/src/store/user/user.mutations.ts index 12a83b071..04111fc2f 100644 --- a/webroot/src/store/user/user.mutations.ts +++ b/webroot/src/store/user/user.mutations.ts @@ -37,6 +37,8 @@ export enum MutationTypes { UPDATE_ACCOUNT_FAILURE = '[User] Update Account Failure', UPDATE_ACCOUNT_SUCCESS = '[User] Update Account Success', SET_REFRESH_TIMEOUT_ID = '[User] Set Refresh Timeout ID', + SET_LOGOUT_TIMEOUT_ID = '[User] Set Logout Timeout ID', + UPDATE_AUTO_LOGOUT_WARNING = '[User] Update Auto Logout Warning', GET_PRIVILEGE_PURCHASE_INFORMATION_REQUEST = '[User] Get Privilege Purchase Information Request', GET_PRIVILEGE_PURCHASE_INFORMATION_SUCCESS = '[User] Get Privilege Purchase Information Success', GET_PRIVILEGE_PURCHASE_INFORMATION_FAILURE = '[User] Get Privilege Purchase Information Failure', @@ -160,6 +162,8 @@ export default { state.isLoggedIn = false; state.isLoadingAccount = false; state.refreshTokenTimeoutId = null; + state.autoLogoutTimeoutId = null; + state.isAutoLogoutWarning = false; state.userType = null; state.currentCompact = null; state.error = null; @@ -179,6 +183,12 @@ export default { [MutationTypes.SET_REFRESH_TIMEOUT_ID]: (state: any, timeoutId: number|null) => { state.refreshTokenTimeoutId = timeoutId; }, + [MutationTypes.SET_LOGOUT_TIMEOUT_ID]: (state: any, timeoutId: number|null) => { + state.autoLogoutTimeoutId = timeoutId; + }, + [MutationTypes.UPDATE_AUTO_LOGOUT_WARNING]: (state: any, isWarning: boolean) => { + state.isAutoLogoutWarning = isWarning; + }, [MutationTypes.GET_PRIVILEGE_PURCHASE_INFORMATION_REQUEST]: (state: any) => { state.isLoadingPrivilegePurchaseOptions = true; state.error = null; diff --git a/webroot/src/store/user/user.spec.ts b/webroot/src/store/user/user.spec.ts index 74289d158..a5169e01c 100644 --- a/webroot/src/store/user/user.spec.ts +++ b/webroot/src/store/user/user.spec.ts @@ -252,6 +252,22 @@ describe('Use Store Mutations', () => { expect(state.refreshTokenTimeoutId).to.equal(timeoutId); }); + it('should successfully set auto logout timeout id', () => { + const state = {}; + const timeoutId = 1; + + mutations[MutationTypes.SET_LOGOUT_TIMEOUT_ID](state, timeoutId); + + expect(state.autoLogoutTimeoutId).to.equal(timeoutId); + }); + it('should successfully update auto logout warning', () => { + const state = {}; + const isWarning = true; + + mutations[MutationTypes.UPDATE_AUTO_LOGOUT_WARNING](state, isWarning); + + expect(state.isAutoLogoutWarning).to.equal(isWarning); + }); it('should successfully upload military affiliation request', () => { const state = {}; @@ -774,6 +790,43 @@ describe('User Store Actions', async () => { expect(dispatch.callCount).to.equal(2); }); + it('should successfully start auto logout inactivity timer', () => { + const dispatch = sinon.spy(); + const state = { isLoggedIn: true }; + + actions.startAutoLogoutInactivityTimer({ dispatch, state }); + + expect(dispatch.callCount).to.equal(2); + expect([dispatch.firstCall.args[0]]).to.matchPattern(['clearAutoLogoutTimeout']); + expect([dispatch.secondCall.args[0]]).to.matchPattern(['setAutoLogoutTimeout']); + }); + it('should successfully set auto logout timeout (staff)', () => { + const commit = sinon.spy(); + const dispatch = sinon.spy(); + const state = { isLoggedInAsStaff: true }; + + actions.setAutoLogoutTimeout({ commit, dispatch, state }); + + expect(commit.calledOnce).to.equal(true); + }); + it('should successfully set auto logout timeout (licensee)', () => { + const commit = sinon.spy(); + const dispatch = sinon.spy(); + const state = { isLoggedInAsLicensee: true }; + + actions.setAutoLogoutTimeout({ commit, dispatch, state }); + + expect(commit.calledOnce).to.equal(true); + }); + it('should successfully clear auto logout timeout', () => { + const commit = sinon.spy(); + const state = { autoLogoutTimeoutId: 1 }; + + actions.clearAutoLogoutTimeout({ commit, state }); + + expect(commit.calledOnce).to.equal(true); + expect(commit.firstCall.args).to.matchPattern([MutationTypes.SET_LOGOUT_TIMEOUT_ID, null]); + }); it('should successfully clear session stores', () => { const dispatch = sinon.spy(); diff --git a/webroot/src/store/user/user.state.ts b/webroot/src/store/user/user.state.ts index e3a821b86..b8d35a586 100644 --- a/webroot/src/store/user/user.state.ts +++ b/webroot/src/store/user/user.state.ts @@ -26,6 +26,8 @@ export interface State { isLoadingCompactStates: boolean; isLoadingPrivilegePurchaseOptions: boolean; refreshTokenTimeoutId: number | null; + isAutoLogoutWarning: boolean; + autoLogoutTimeoutId: number | null; currentCompact: Compact | null; purchase: PurchaseFlowState; error: any | null; @@ -41,6 +43,8 @@ export const state: State = { isLoadingCompactStates: false, isLoadingPrivilegePurchaseOptions: false, refreshTokenTimeoutId: null, + isAutoLogoutWarning: false, + autoLogoutTimeoutId: null, currentCompact: null, purchase: new PurchaseFlowState(), error: null,