From 0812dee7eee633b150f43b1ba339d1bb96370d8f Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 10 Nov 2025 14:15:18 -0700 Subject: [PATCH 1/6] WIP: auto logout - Initial store timers and dom events - @todo: Create the modal UI flow --- webroot/src/components/App/App.ts | 49 ++++++++++++++++++++++++ webroot/src/store/user/user.actions.ts | 45 ++++++++++++++++++++++ webroot/src/store/user/user.mutations.ts | 9 +++++ webroot/src/store/user/user.state.ts | 4 ++ 4 files changed, 107 insertions(+) diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index 85b4c7f78..a03248b28 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -40,6 +40,7 @@ class App extends Vue { // body = document.body; featureGateFetchIntervalId: number | undefined = undefined; + autoLogoutEventsController: AbortController | null = null; // // Lifecycle @@ -55,6 +56,7 @@ class App extends Vue { async beforeUnmount() { this.clearFeatureGateRefetchInterval(); + this.removeAutoLogoutEvents(); } // @@ -98,6 +100,7 @@ class App extends Vue { this.$store.dispatch('user/startRefreshTokenTimer', authType); await this.getAccount(); await this.setCurrentCompact(); + this.startAutoLogoutTimer(); } } @@ -192,6 +195,52 @@ class App extends Vue { } } + startAutoLogoutTimer(): void { + const resetEvents = [ + 'mousemove', + 'mousedown', + 'click', + 'keypress', + 'touchstart', + 'touchend', + 'touchmove', + 'onscroll', + 'wheel', + 'mousewheel', + ]; + const eventHandler = (event) => { + console.log(`event: ${event.type}`); + this.$store.dispatch('user/startAutoLogoutTokenTimer'); + this.startAutoLogoutTimer(); + }; + const controller = new AbortController(); + const { isLoggedIn, isAutoLogoutWarning } = this.userStore; + + this.removeAutoLogoutEvents(); + + if (isLoggedIn && !isAutoLogoutWarning) { + this.$store.dispatch('user/startAutoLogoutTokenTimer'); + this.autoLogoutEventsController = controller; + resetEvents.forEach((resetEvent) => { + document.addEventListener(resetEvent, eventHandler, { + capture: false, + once: true, + passive: true, + signal: controller.signal, + }); + }); + } + } + + removeAutoLogoutEvents(): void { + const { autoLogoutEventsController } = this; + + if (autoLogoutEventsController) { + autoLogoutEventsController.abort(); + this.autoLogoutEventsController = null; + } + } + setRelativeTimeFormats() { // https://momentjs.com/docs/#/customization/relative-time/ moment.updateLocale('en', { diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 140867cdc..aeb8bbd4a 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -275,6 +275,51 @@ export default { clearTimeout(refreshTokenTimeoutId); commit(MutationTypes.SET_REFRESH_TIMEOUT_ID, null); }, + startAutoLogoutTokenTimer: ({ dispatch, state }) => { + const { isLoggedIn, isAutoLogoutWarning } = state; + + dispatch('clearAutoLogoutTimeout'); + + if (isLoggedIn && !isAutoLogoutWarning) { + dispatch('setAutoLogoutTimeout'); + } + }, + setAutoLogoutTimeout: async ({ commit, dispatch, state }) => { + const { isLoggedInAsStaff, isLoggedInAsLicensee } = state; + let initiateInMs = moment.duration(10, 'minutes').asMilliseconds(); // Default inactivity timer + + if (isLoggedInAsStaff) { + initiateInMs = moment.duration(5, 'seconds').asMilliseconds(); // Inactivity timer for Staff + } else if (isLoggedInAsLicensee) { + initiateInMs = moment.duration(5, 'seconds').asMilliseconds(); // Inactivity timer for Licensees + } + + const initiateAutoLogout = () => { + dispatch('clearAutoLogoutTimeout'); + dispatch('updateAutoLogoutWarning', true); + console.log(`auto logout warning: ${state.isAutoLogoutWarning}`); + console.log(`auto logout timer: ${state.autoLogoutTimeoutId}`); + }; + const timeoutId = setTimeout(initiateAutoLogout, initiateInMs); + + console.log(`timer started in store`); + console.log(`initiateInMs: ${initiateInMs}`); + console.log(``); + + commit(MutationTypes.SET_LOGOUT_TIMEOUT_ID, timeoutId); + }, + clearAutoLogoutTimeout: ({ commit, state }) => { + const { autoLogoutTimeoutId } = state; + + if (autoLogoutTimeoutId) { + clearTimeout(autoLogoutTimeoutId); + } + + 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..36717f761 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,7 @@ export default { state.isLoggedIn = false; state.isLoadingAccount = false; state.refreshTokenTimeoutId = null; + state.autoLogoutTimeoutId = null; state.userType = null; state.currentCompact = null; state.error = null; @@ -179,6 +182,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.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, From e99300371da4b2e6a50c0eb8acde2aba8c52100a Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 11 Nov 2025 14:11:55 -0700 Subject: [PATCH 2/6] WIP: auto logout - Add debounce to activity event handlers - @todo: Create the modal UI flow --- webroot/src/components/App/App.ts | 15 ++++++++++++++- webroot/src/store/user/user.actions.ts | 4 ++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index a03248b28..32d7ad462 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -208,11 +208,22 @@ class App extends Vue { 'wheel', 'mousewheel', ]; + const debounce = (func, delay) => { + let timer; + + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, delay); + }; + }; const eventHandler = (event) => { console.log(`event: ${event.type}`); this.$store.dispatch('user/startAutoLogoutTokenTimer'); this.startAutoLogoutTimer(); }; + const debouncedEventHandler = debounce(eventHandler, 1000); const controller = new AbortController(); const { isLoggedIn, isAutoLogoutWarning } = this.userStore; @@ -222,7 +233,7 @@ class App extends Vue { this.$store.dispatch('user/startAutoLogoutTokenTimer'); this.autoLogoutEventsController = controller; resetEvents.forEach((resetEvent) => { - document.addEventListener(resetEvent, eventHandler, { + document.addEventListener(resetEvent, debouncedEventHandler, { capture: false, once: true, passive: true, @@ -261,6 +272,7 @@ class App extends Vue { @Watch('userStore.isLoggedInAsLicensee') async handleLicenseeLogin() { if (!this.userStore.isLoggedIn) { + this.removeAutoLogoutEvents(); this.$router.push({ name: 'Logout' }); } else if (this.userStore.isLoggedInAsLicensee) { await this.handleAuth(); @@ -269,6 +281,7 @@ class App extends Vue { @Watch('userStore.isLoggedInAsStaff') async handleStaffLogin() { if (!this.userStore.isLoggedIn) { + this.removeAutoLogoutEvents(); this.$router.push({ name: 'Logout' }); } else if (this.userStore.isLoggedInAsStaff) { await this.handleAuth(); diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index aeb8bbd4a..cf054625b 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -289,9 +289,9 @@ export default { let initiateInMs = moment.duration(10, 'minutes').asMilliseconds(); // Default inactivity timer if (isLoggedInAsStaff) { - initiateInMs = moment.duration(5, 'seconds').asMilliseconds(); // Inactivity timer for Staff + initiateInMs = moment.duration(10, 'seconds').asMilliseconds(); // Inactivity timer for Staff } else if (isLoggedInAsLicensee) { - initiateInMs = moment.duration(5, 'seconds').asMilliseconds(); // Inactivity timer for Licensees + initiateInMs = moment.duration(10, 'seconds').asMilliseconds(); // Inactivity timer for Licensees } const initiateAutoLogout = () => { From e92d5268fea35e0e3abd7d71975bc6434da3348e Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 13 Nov 2025 12:18:49 -0700 Subject: [PATCH 3/6] WIP: auto logout - Add modal UI flow - @todo: Testing & polish --- webroot/src/app.config.ts | 18 ++ webroot/src/components/App/App.ts | 64 +----- webroot/src/components/App/App.vue | 1 + .../src/components/AutoLogout/AutoLogout.less | 65 +++++++ .../components/AutoLogout/AutoLogout.spec.ts | 19 ++ .../src/components/AutoLogout/AutoLogout.ts | 183 ++++++++++++++++++ .../src/components/AutoLogout/AutoLogout.vue | 39 ++++ webroot/src/locales/en.json | 5 +- webroot/src/locales/es.json | 5 +- webroot/src/store/user/user.actions.ts | 19 +- webroot/src/store/user/user.mutations.ts | 1 + webroot/src/store/user/user.spec.ts | 53 +++++ 12 files changed, 398 insertions(+), 74 deletions(-) create mode 100644 webroot/src/components/AutoLogout/AutoLogout.less create mode 100644 webroot/src/components/AutoLogout/AutoLogout.spec.ts create mode 100644 webroot/src/components/AutoLogout/AutoLogout.ts create mode 100644 webroot/src/components/AutoLogout/AutoLogout.vue diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 6b58f89c5..461e10d77 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, 'seconds').asMilliseconds(), + INACTIVITY_TIMER_LICENSEE_MS: moment.duration(10, 'seconds').asMilliseconds(), + GRACE_PERIOD_MS: moment.duration(10, '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 32d7ad462..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' @@ -40,7 +42,6 @@ class App extends Vue { // body = document.body; featureGateFetchIntervalId: number | undefined = undefined; - autoLogoutEventsController: AbortController | null = null; // // Lifecycle @@ -56,7 +57,6 @@ class App extends Vue { async beforeUnmount() { this.clearFeatureGateRefetchInterval(); - this.removeAutoLogoutEvents(); } // @@ -100,7 +100,6 @@ class App extends Vue { this.$store.dispatch('user/startRefreshTokenTimer', authType); await this.getAccount(); await this.setCurrentCompact(); - this.startAutoLogoutTimer(); } } @@ -195,63 +194,6 @@ class App extends Vue { } } - startAutoLogoutTimer(): void { - const resetEvents = [ - 'mousemove', - 'mousedown', - 'click', - 'keypress', - 'touchstart', - 'touchend', - 'touchmove', - 'onscroll', - 'wheel', - 'mousewheel', - ]; - const debounce = (func, delay) => { - let timer; - - return (...args) => { - clearTimeout(timer); - timer = setTimeout(() => { - func.apply(this, args); - }, delay); - }; - }; - const eventHandler = (event) => { - console.log(`event: ${event.type}`); - this.$store.dispatch('user/startAutoLogoutTokenTimer'); - this.startAutoLogoutTimer(); - }; - const debouncedEventHandler = debounce(eventHandler, 1000); - const controller = new AbortController(); - const { isLoggedIn, isAutoLogoutWarning } = this.userStore; - - this.removeAutoLogoutEvents(); - - if (isLoggedIn && !isAutoLogoutWarning) { - this.$store.dispatch('user/startAutoLogoutTokenTimer'); - this.autoLogoutEventsController = controller; - resetEvents.forEach((resetEvent) => { - document.addEventListener(resetEvent, debouncedEventHandler, { - capture: false, - once: true, - passive: true, - signal: controller.signal, - }); - }); - } - } - - removeAutoLogoutEvents(): void { - const { autoLogoutEventsController } = this; - - if (autoLogoutEventsController) { - autoLogoutEventsController.abort(); - this.autoLogoutEventsController = null; - } - } - setRelativeTimeFormats() { // https://momentjs.com/docs/#/customization/relative-time/ moment.updateLocale('en', { @@ -272,7 +214,6 @@ class App extends Vue { @Watch('userStore.isLoggedInAsLicensee') async handleLicenseeLogin() { if (!this.userStore.isLoggedIn) { - this.removeAutoLogoutEvents(); this.$router.push({ name: 'Logout' }); } else if (this.userStore.isLoggedInAsLicensee) { await this.handleAuth(); @@ -281,7 +222,6 @@ class App extends Vue { @Watch('userStore.isLoggedInAsStaff') async handleStaffLogin() { if (!this.userStore.isLoggedIn) { - this.removeAutoLogoutEvents(); this.$router.push({ name: 'Logout' }); } else if (this.userStore.isLoggedInAsStaff) { await this.handleAuth(); 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..f86f45d3d --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.less @@ -0,0 +1,65 @@ +// +// 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 { + 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..3c6e7f75e --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.ts @@ -0,0 +1,183 @@ +// +// AutoLogout.ts +// CompactConnect +// +// Created by InspiringApps on 11/13/2025. +// + +import { + Component, + Vue, + Watch, + toNative +} from 'vue-facing-decorator'; +import { nextTick } from 'vue'; +import { autoLogoutConfig } from '@/app.config'; +import Modal from '@components/Modal/Modal.vue'; +import InputButton from '@components/Forms/InputButton/InputButton.vue'; + +@Component({ + name: 'AutoLogout', + components: { + Modal, + InputButton, + }, +}) +class AutoLogout extends Vue { + // + // 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.startAutoLogoutInactivityTimer(); + } + } + + async beforeUnmount() { + this.removeAutoLogoutEvents(); + } + + // + // Computed + // + get userStore() { + return this.$store.state.user; + } + + // + // Methods + // + 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..08ce767cb --- /dev/null +++ b/webroot/src/components/AutoLogout/AutoLogout.vue @@ -0,0 +1,39 @@ + + + + + + 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 cf054625b..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,7 +276,7 @@ export default { clearTimeout(refreshTokenTimeoutId); commit(MutationTypes.SET_REFRESH_TIMEOUT_ID, null); }, - startAutoLogoutTokenTimer: ({ dispatch, state }) => { + startAutoLogoutInactivityTimer: ({ dispatch, state }) => { const { isLoggedIn, isAutoLogoutWarning } = state; dispatch('clearAutoLogoutTimeout'); @@ -286,25 +287,22 @@ export default { }, setAutoLogoutTimeout: async ({ commit, dispatch, state }) => { const { isLoggedInAsStaff, isLoggedInAsLicensee } = state; - let initiateInMs = moment.duration(10, 'minutes').asMilliseconds(); // Default inactivity timer + let initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_DEFAULT_MS; // Default inactivity timer if (isLoggedInAsStaff) { - initiateInMs = moment.duration(10, 'seconds').asMilliseconds(); // Inactivity timer for Staff + initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_STAFF_MS; // Inactivity timer for Staff } else if (isLoggedInAsLicensee) { - initiateInMs = moment.duration(10, 'seconds').asMilliseconds(); // Inactivity timer for Licensees + initiateInMs = autoLogoutConfig.INACTIVITY_TIMER_LICENSEE_MS; // Inactivity timer for Licensees } const initiateAutoLogout = () => { dispatch('clearAutoLogoutTimeout'); dispatch('updateAutoLogoutWarning', true); - console.log(`auto logout warning: ${state.isAutoLogoutWarning}`); - console.log(`auto logout timer: ${state.autoLogoutTimeoutId}`); + autoLogoutConfig.LOG(`auto logout warning: ${state.isAutoLogoutWarning}`); }; const timeoutId = setTimeout(initiateAutoLogout, initiateInMs); - console.log(`timer started in store`); - console.log(`initiateInMs: ${initiateInMs}`); - console.log(``); + autoLogoutConfig.LOG(`timer started in store`); commit(MutationTypes.SET_LOGOUT_TIMEOUT_ID, timeoutId); }, @@ -313,6 +311,7 @@ export default { if (autoLogoutTimeoutId) { clearTimeout(autoLogoutTimeoutId); + autoLogoutConfig.LOG(`timer cleared in store`); } commit(MutationTypes.SET_LOGOUT_TIMEOUT_ID, null); diff --git a/webroot/src/store/user/user.mutations.ts b/webroot/src/store/user/user.mutations.ts index 36717f761..04111fc2f 100644 --- a/webroot/src/store/user/user.mutations.ts +++ b/webroot/src/store/user/user.mutations.ts @@ -163,6 +163,7 @@ export default { state.isLoadingAccount = false; state.refreshTokenTimeoutId = null; state.autoLogoutTimeoutId = null; + state.isAutoLogoutWarning = false; state.userType = null; state.currentCompact = null; 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(); From d82e1dd8ed286e15cc0b3196c1ac44974a990b01 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 17 Nov 2025 10:11:22 -0700 Subject: [PATCH 4/6] WIP: auto logout - Set initial timer durations --- webroot/src/app.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 461e10d77..b1088e695 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -121,9 +121,9 @@ export const AUTH_LOGIN_GOTO_PATH_AUTH_TYPE = 'login_goto_auth_type'; // ==================== export const autoLogoutConfig = { INACTIVITY_TIMER_DEFAULT_MS: moment.duration(10, 'minutes').asMilliseconds(), - INACTIVITY_TIMER_STAFF_MS: moment.duration(10, 'seconds').asMilliseconds(), - INACTIVITY_TIMER_LICENSEE_MS: moment.duration(10, 'seconds').asMilliseconds(), - GRACE_PERIOD_MS: moment.duration(10, 'seconds').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 From 41c956a66582e44563a4003289327163f4d42bd2 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 17 Nov 2025 10:44:25 -0700 Subject: [PATCH 5/6] WIP: auto logout - Update to submit button --- .../src/components/AutoLogout/AutoLogout.ts | 22 ++++++++++++++----- .../src/components/AutoLogout/AutoLogout.vue | 19 ++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/webroot/src/components/AutoLogout/AutoLogout.ts b/webroot/src/components/AutoLogout/AutoLogout.ts index 3c6e7f75e..a2fa21481 100644 --- a/webroot/src/components/AutoLogout/AutoLogout.ts +++ b/webroot/src/components/AutoLogout/AutoLogout.ts @@ -7,23 +7,25 @@ import { Component, - Vue, + mixins, Watch, toNative } from 'vue-facing-decorator'; -import { nextTick } from 'vue'; +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 InputButton from '@components/Forms/InputButton/InputButton.vue'; +import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; +import { FormInput } from '@/models/FormInput/FormInput.model'; @Component({ name: 'AutoLogout', components: { Modal, - InputButton, + InputSubmit, }, }) -class AutoLogout extends Vue { +class AutoLogout extends mixins(MixinForm) { // // Data // @@ -49,6 +51,7 @@ class AutoLogout extends Vue { // async created() { if (this.userStore.isLoggedIn) { + this.initFormInputs(); this.startAutoLogoutInactivityTimer(); } } @@ -67,6 +70,15 @@ class AutoLogout extends Vue { // // 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(); diff --git a/webroot/src/components/AutoLogout/AutoLogout.vue b/webroot/src/components/AutoLogout/AutoLogout.vue index 08ce767cb..f004019bd 100644 --- a/webroot/src/components/AutoLogout/AutoLogout.vue +++ b/webroot/src/components/AutoLogout/AutoLogout.vue @@ -21,15 +21,16 @@ aria-live="assertive" role="status" > -
{{ $t('account.autoLogoutWarningDescription') }}
-
- -
+
+
{{ $t('account.autoLogoutWarningDescription') }}
+
+ +
+
From 3686ba1f1c2446fdbf28d67c7f0fab3c6447d0c3 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Wed, 19 Nov 2025 08:55:31 -0700 Subject: [PATCH 6/6] PR review feedback --- webroot/src/components/AutoLogout/AutoLogout.less | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webroot/src/components/AutoLogout/AutoLogout.less b/webroot/src/components/AutoLogout/AutoLogout.less index f86f45d3d..2de79067d 100644 --- a/webroot/src/components/AutoLogout/AutoLogout.less +++ b/webroot/src/components/AutoLogout/AutoLogout.less @@ -55,7 +55,8 @@ } } - .input-button { + .input-button, + .input-submit { width: 100%; } }