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
2 changes: 2 additions & 0 deletions webroot/src/components/Page/PageContainer/PageContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class PageContainer extends Vue {

get shouldPadTop(): boolean {
const nonPadTopRouteNames: Array<string> = [
'Logout',
'LicensingDetail',
'LicenseeDetailPublic',
'LicenseeVerification',
Expand All @@ -70,6 +71,7 @@ class PageContainer extends Vue {

get includeMainNav(): boolean {
const nonMainNavRouteNames: Array<string> = [
'Logout', // This is a non-interactive page with background operations and an automatic redirect
'DashboardPublic', // This is a custom splash page with custom button navigation
'LicenseeVerification', // This is a printer-friendly page
'MfaResetConfirmLicensee', // This is a standalone automation page accessed from emailed link
Expand Down
1 change: 1 addition & 0 deletions webroot/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"status": "Status",
"saving": "Saving...",
"loading": "Loading...",
"loggingOut": "Logging out...",
"viewing": "Viewing",
"viewDetails": "View details",
"name": "Name",
Expand Down
1 change: 1 addition & 0 deletions webroot/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"saveAndClose": "Guardar y cerrar",
"saving": "Guardando...",
"loading": "Cargando...",
"loggingOut": "Cerrando sesión...",
"viewing": "Viendo",
"viewDetails": "Ver detalles",
"name": "Nombre",
Expand Down
56 changes: 50 additions & 6 deletions webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@
// Created by InspiringApps on 6/24/2026.
//

import sinon from 'sinon';
import axios from 'axios';
import { mountShallow } from '@tests/helpers/setup';
import AuthCallbackHandlerMixin from '@pages/AuthCallback/_mixins/handler.mixin';
import { AppModes } from '@/app.config';
import { AuthTypes, AUTH_CSRF_STATE } from '@utils/auth';
import {
AuthTypes,
AUTH_CSRF_STATE,
AUTH_PKCE_CODE_VERIFIER,
authStorage,
tokens
} from '@utils/auth';
import sessionStorage from '@store/session.storage';

const chaiMatchPattern = require('chai-match-pattern');
Expand Down Expand Up @@ -41,13 +49,49 @@ describe('AuthCallbackHandler mixin', async () => {
expect(component.stateParam).to.equal('def');
});
it('should successfully get tokens', async () => {
const cognitoAuthDomain = 'https://staff-auth.test.example.com';
const cognitoClientId = 'test-staff-client-id';
const tokenResponse = {
access_token: 'access-token',
id_token: 'id-token',
token_type: 'Bearer',
};
const axiosPostStub = sinon.stub(axios, 'post').resolves({ data: tokenResponse });
const wrapper = await mountShallow(AuthCallbackHandlerMixin);
const component = wrapper.vm;

await component.getTokens(AppModes.JCC, AuthTypes.STAFF, 'http://localhost', 'abc');

// If the tokens flow is successful then it ends by redirecting the user with a replaced router history state
expect(component.$router.options.history.state.replaced).to.equal(true);
const routerPushStub = sinon.stub(component.$router, 'push').resolves();
const dispatchSpy = sinon.spy(component.$store, 'dispatch');

// created() fails CSRF and sets isError; reset for the direct getTokens call under test
component.isError = false;
component.$route.query.code = 'auth-code-123';
sessionStorage.setItem(AUTH_PKCE_CODE_VERIFIER, 'pkce-verifier-123');
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await component.getTokens(AppModes.JCC, AuthTypes.STAFF, cognitoAuthDomain, cognitoClientId);

expect(axiosPostStub.calledOnce).to.equal(true);
expect(axiosPostStub.firstCall.args[0]).to.equal(`${cognitoAuthDomain}/oauth2/token`);
expect(axiosPostStub.firstCall.args[1].get('grant_type')).to.equal('authorization_code');
expect(axiosPostStub.firstCall.args[1].get('client_id')).to.equal(cognitoClientId);
expect(axiosPostStub.firstCall.args[1].get('redirect_uri')).to.equal(
`${component.$envConfig.domain}${component.$route.path}`
);
expect(axiosPostStub.firstCall.args[1].get('code')).to.equal('auth-code-123');
expect(axiosPostStub.firstCall.args[1].get('code_verifier')).to.equal('pkce-verifier-123');
expect(dispatchSpy.calledWith('user/updateAuthTokens', {
tokenResponse,
authType: AuthTypes.STAFF,
})).to.equal(true);
expect(dispatchSpy.calledWith('user/loginSuccess', AuthTypes.STAFF)).to.equal(true);
expect(routerPushStub.calledWith({ name: 'Home' })).to.equal(true);
expect(component.isError).to.equal(false);

axiosPostStub.restore();
routerPushStub.restore();
dispatchSpy.restore();
authStorage.removeItem(tokens.staff.AUTH_TOKEN);
authStorage.removeItem(tokens.staff.AUTH_TOKEN_TYPE);
authStorage.removeItem(tokens.staff.ID_TOKEN);
});
it('should verify a matching csrf state param', async () => {
const wrapper = await mountShallow(AuthCallbackHandlerMixin);
Expand Down
13 changes: 13 additions & 0 deletions webroot/src/pages/Logout/Logout.less
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,16 @@
//
// Created by InspiringApps on 8/12/2024.
//

.logout-container {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;

.message {
padding-bottom: 6.4rem;
}
}
111 changes: 111 additions & 0 deletions webroot/src/pages/Logout/Logout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// Logout.spec.ts
// CompactConnect
//
// Created by InspiringApps on 7/29/2026.
//

import sinon from 'sinon';
import axios from 'axios';
import { mountShallow } from '@tests/helpers/setup';
import Logout from '@pages/Logout/Logout.vue';
import { authStorage, tokens, AuthTypes } from '@utils/auth';
import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin';

const chaiMatchPattern = require('chai-match-pattern');
const chai = require('chai').use(chaiMatchPattern);

const { expect } = chai;

describe('Logout page', async () => {
let logoutStub;
let originalCognitoConfig;

beforeEach(() => {
// Prevent created() from running real logout (clears shared store / redirects)
logoutStub = sinon.stub(Logout.methods, 'logout').resolves();

originalCognitoConfig = {
cognitoClientIdStaff: envConfig.cognitoClientIdStaff,
cognitoAuthDomainStaff: envConfig.cognitoAuthDomainStaff,
};
envConfig.cognitoClientIdStaff = 'test-staff-client-id';
envConfig.cognitoAuthDomainStaff = 'https://staff-auth.test.example.com';
});

afterEach(() => {
logoutStub.restore();
envConfig.cognitoClientIdStaff = originalCognitoConfig.cognitoClientIdStaff;
envConfig.cognitoAuthDomainStaff = originalCognitoConfig.cognitoAuthDomainStaff;
authStorage.removeItem(tokens.staff.REFRESH_TOKEN);
});

it('should mount the page component', async () => {
const wrapper = await mountShallow(Logout);

expect(wrapper.exists()).to.equal(true);
expect(wrapper.findComponent(Logout).exists()).to.equal(true);
expect(logoutStub.calledOnce).to.equal(true);
});

it('should successfully revoke tokens before logoutRequest in logoutChecklist', async () => {
const wrapper = await mountShallow(Logout);
const component = wrapper.vm;
const revokeStub = sinon.stub(component, 'revokeTokens').resolves();
const dispatchSpy = sinon.spy(component.$store, 'dispatch');

await component.logoutChecklist(false);

expect(revokeStub.calledOnce).to.equal(true);
expect(revokeStub.firstCall.args[0]).to.equal(AuthTypes.STAFF);
expect(dispatchSpy.calledWith('user/logoutRequest', AuthTypes.STAFF)).to.equal(true);
expect(revokeStub.calledBefore(
dispatchSpy.withArgs('user/logoutRequest', AuthTypes.STAFF)
)).to.equal(true);

revokeStub.restore();
dispatchSpy.restore();
});

it('should successfully revoke licensee tokens when logged in as licensee only', async () => {
const wrapper = await mountShallow(Logout);
const component = wrapper.vm;
const revokeStub = sinon.stub(component, 'revokeTokens').resolves();

await component.logoutChecklist(true);

expect(revokeStub.firstCall.args[0]).to.equal(AuthTypes.LICENSEE);

revokeStub.restore();
});

it('should successfully swallow revoke errors and log to analytics', async () => {
const wrapper = await mountShallow(Logout);
const component = wrapper.vm;
const axiosPostStub = sinon.stub(axios, 'post').rejects(new Error('network'));
const logEventStub = sinon.stub(component.$analytics, 'logEvent').returns(undefined);
Comment thread
jlkravitz marked this conversation as resolved.
let didThrow = false;

authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token');

await component.revokeTokens(AuthTypes.STAFF).catch(() => {
didThrow = true;
});

expect(didThrow).to.equal(false);
expect(logEventStub.calledOnce).to.equal(true);
expect(logEventStub.firstCall.args[0]).to.equal('cognito_token_revoke_failed'); // https://console.statsig.com/3KcYv8LC2YCc1vsTkVi3Fb/metrics/metrics_catalog/Cognito%20Token%20Revocation%20Failure/event_count_custom?unitType=overall
expect(logEventStub.firstCall.args[1]).to.equal(1);
expect(logEventStub.firstCall.args[2]).to.matchPattern({
authType: AuthTypes.STAFF,
appMode: component.appMode,
appGroupMode: component.appGroupMode,
errorName: 'Error',
errorCode: undefined,
httpStatus: undefined,
});

axiosPostStub.restore();
logEventStub.restore();
});
});
37 changes: 30 additions & 7 deletions webroot/src/pages/Logout/Logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ import {
AuthTypes,
AUTH_TYPE,
AUTH_LOGIN_GOTO_PATH,
AUTH_LOGIN_GOTO_PATH_AUTH_TYPE
AUTH_LOGIN_GOTO_PATH_AUTH_TYPE,
revokeCognitoRefreshToken
} from '@utils/auth';
import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue';

@Component({
name: 'Logout',
components: {}
components: {
LoadingSpinner,
},
})
export default class Logout extends Vue {
//
Expand All @@ -35,6 +39,10 @@ export default class Logout extends Vue {
return this.$store.state.appMode;
}

get appGroupMode() {
return this.$store.state.appGroupMode;
}

get userStore() {
return this.$store.state.user;
}
Expand Down Expand Up @@ -117,18 +125,33 @@ export default class Logout extends Vue {
async logoutChecklist(isRemoteLoggedInAsLicenseeOnly): Promise<void> {
const authType = (isRemoteLoggedInAsLicenseeOnly) ? AuthTypes.LICENSEE : AuthTypes.STAFF;

this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps
this.stashWorkingUri();
this.$store.dispatch('user/clearRefreshTokenTimeout');
await this.revokeTokens(authType);
this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps
await this.$store.dispatch('user/logoutRequest', authType);
}

async revokeTokens(authType: AuthTypes): Promise<void> {
await revokeCognitoRefreshToken(this.appMode, authType).catch((err) => Promise.resolve().then(() => {
// https://console.statsig.com/3KcYv8LC2YCc1vsTkVi3Fb/metrics/metrics_catalog/Cognito%20Token%20Revocation%20Failure/event_count_custom?unitType=overall
this.$analytics.logEvent('cognito_token_revoke_failed', 1, {
authType,
appMode: this.appMode,
appGroupMode: this.appGroupMode,
errorName: err?.name,
errorCode: err?.code,
httpStatus: err?.response?.status,
});
}).catch(() => {
// Continue — analytics failures must never block logout
}));
}

async unsetAnalyticsUser(): Promise<void> {
try {
await this.$analytics.updateUserAsync({});
} catch (err) {
await this.$analytics.updateUserAsync({}).catch(() => {
// Continue
}
});
}

stashWorkingUri(): void {
Expand Down
5 changes: 4 additions & 1 deletion webroot/src/pages/Logout/Logout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
-->

<template>
<div class="logout-container"></div>
<div class="logout-container">
<div class="message">{{ $t('common.loggingOut') }}</div>
<LoadingSpinner :noBgColor="true" />
</div>
</template>

<script lang="ts" src="./Logout.ts"></script>
Expand Down
4 changes: 3 additions & 1 deletion webroot/src/pages/PrivilegePurchase/PrivilegePurchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ export default class PrivilegePurchase extends Vue {
// Watchers
//
@Watch('routeName') handlePurchaseFlowNavigation() {
this.handlePurchaseFlowState();
if (this.licensee?.canPurchasePrivileges() && this.currentCompactType) {
this.handlePurchaseFlowState();
}
}
}
3 changes: 3 additions & 0 deletions webroot/src/plugins/Statsig/statsig.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const getStatsigEnvironment = () => {
export type StatsigClientMock = {
updateUserAsync: (user: any) => Promise<any>;
checkGate: (gateId?: string) => boolean;
logEvent: (eventName?: string, value?: string | number | null, metadata?: Record<string, any>) => boolean;
}

export const getStatsigClientMock = async (isLiveFallback = false) => ({
Expand All @@ -54,6 +55,8 @@ export const getStatsigClientMock = async (isLiveFallback = false) => ({

return isEnabled;
},
// Returns truthy so chai-match-pattern (which treats functions as predicates) still matches across mock instances
logEvent: () => true,
});

export const getStatsigClient = async () => {
Expand Down
10 changes: 1 addition & 9 deletions webroot/src/store/user/user.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,14 @@ export default {
// LOGOUT
logoutRequest: ({ commit, dispatch }, authType) => {
dispatch('clearSessionStores');
dispatch('startLoading', null, { root: true });
let tokenType = AuthTypes.STAFF;

if (authType === AuthTypes.LICENSEE) {
tokenType = authType;
}
dispatch('clearAuthToken', tokenType);
commit(MutationTypes.LOGOUT_REQUEST);

/* istanbul ignore next */
if (config.isUsingMockApi) {
setTimeout(() => dispatch('endLoading', null, { root: true }), 1000);
dispatch('logoutSuccess');
} else {
dispatch('logoutSuccess');
}
dispatch('logoutSuccess');
},
logoutSuccess: ({ commit }) => {
commit(MutationTypes.LOGOUT_SUCCESS);
Expand Down
2 changes: 1 addition & 1 deletion webroot/src/store/user/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,7 @@ describe('User Store Actions', async () => {

expect(commit.calledOnce).to.equal(true);
expect(commit.firstCall.args).to.matchPattern([MutationTypes.LOGOUT_REQUEST]);
expect(dispatch.callCount).to.equal(4);
expect(dispatch.callCount).to.equal(3);
});
it('should successfully start logout success', () => {
const commit = sinon.spy();
Expand Down
Loading
Loading