Skip to content
Merged
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
18 changes: 18 additions & 0 deletions webroot/src/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 =
// ====================
Expand Down
2 changes: 2 additions & 0 deletions webroot/src/components/App/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -29,6 +30,7 @@ import moment from 'moment';
components: {
PageContainer,
Modal,
AutoLogout,
},
emits: [
'trigger-scroll-behavior'
Expand Down
1 change: 1 addition & 0 deletions webroot/src/components/App/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
</ul>
</template>
</Modal>
<AutoLogout />
</div>
</template>

Expand Down
66 changes: 66 additions & 0 deletions webroot/src/components/AutoLogout/AutoLogout.less
Original file line number Diff line number Diff line change
@@ -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%;
}
}
}
}
}
}
19 changes: 19 additions & 0 deletions webroot/src/components/AutoLogout/AutoLogout.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
195 changes: 195 additions & 0 deletions webroot/src/components/AutoLogout/AutoLogout.ts
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 40 additions & 0 deletions webroot/src/components/AutoLogout/AutoLogout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!--
AutoLogout.vue
CompactConnect

Created by InspiringApps on 11/13/2025.
-->

<template>
<Modal
v-if="userStore.isAutoLogoutWarning && gracePeriodExtendEnabled"
modalId="auto-logout-modal"
class="auto-logout-modal"
:title="$t('account.autoLogoutWarningTitle')"
:showActions="false"
@keydown.tab="focusTrapAutoLogoutModal($event)"
@keyup.esc="staySignedIn"
>
<template v-slot:content>
<div
class="modal-content auto-logout-modal-content"
aria-live="assertive"
role="status"
>
<form class="auto-logout-form" @submit.prevent="staySignedIn">
<div class="description-container">{{ $t('account.autoLogoutWarningDescription') }}</div>
<div class="action-button-row">
<InputSubmit
:formInput="formData.stayLoggedIn"
class="action-button auto-logout-cancel-button"
:label="$t('account.autoLogoutStaySignedIn')"
/>
</div>
</form>
</div>
</template>
</Modal>
</template>

<script lang="ts" src="./AutoLogout.ts"></script>
<style scoped lang="less" src="./AutoLogout.less"></style>
5 changes: 4 additions & 1 deletion webroot/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading