Skip to content
Merged
3 changes: 3 additions & 0 deletions src/modules/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<authPendingRequestBanner />

<organizationsAdminPendingBanner />
<organizationsSuggestedJoinBanner />
<legalCookieBanner />
<legalFooterSection />
<v-main class="pb-0" :style="mainStyle">
Expand Down Expand Up @@ -63,6 +64,7 @@ import authEmailBanner from '../auth/components/emailBanner.component.vue';
import authPendingRequestBanner from '../auth/components/pendingRequestBanner.component.vue';

import organizationsAdminPendingBanner from '../organizations/components/organizations.adminPendingBanner.component.vue';
import organizationsSuggestedJoinBanner from '../organizations/components/organizations.suggestedJoinBanner.component.vue';
import legalCookieBanner from '../legal/components/legal.cookieBanner.component.vue';
import legalFooterSection from '../legal/components/legal.footerSection.component.vue';
import appErrorBoundary from './components/app.errorBoundary.component.vue';
Expand All @@ -80,6 +82,7 @@ export default {
authPendingRequestBanner,

organizationsAdminPendingBanner,
organizationsSuggestedJoinBanner,
legalCookieBanner,
legalFooterSection,
appErrorBoundary,
Expand Down
49 changes: 49 additions & 0 deletions src/modules/auth/stores/auth.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const useAuthStore = defineStore('auth', {
locked: false,
retryAfter: 0,
},
/** @type {{ orgId: string, orgName: string } | null} */
suggestedJoin: null,
}),

getters: {
Expand All @@ -67,6 +69,16 @@ export const useAuthStore = defineStore('auth', {
// Initialize from localStorage
initFromStorage() {
this.cookieExpire = localStorage.getItem(`${config.cookie.prefix}CookieExpire`) || 0;
const rawSuggestedJoin = localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`);
try {
const parsed = rawSuggestedJoin ? JSON.parse(rawSuggestedJoin) : null;
const isValid = parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof parsed.orgId === 'string' && parsed.orgId && typeof parsed.orgName === 'string' && parsed.orgName;
this.suggestedJoin = isValid ? parsed : null;
if (rawSuggestedJoin && !isValid) localStorage.removeItem(`${config.cookie.prefix}SuggestedJoin`);
} catch {
Comment thread
PierreBrisorgueil marked this conversation as resolved.
this.suggestedJoin = null;
localStorage.removeItem(`${config.cookie.prefix}SuggestedJoin`);
}
},

/**
Expand Down Expand Up @@ -151,6 +163,37 @@ export const useAuthStore = defineStore('auth', {
this.lockout = { locked: false, retryAfter: 0 };
},

/**
* @desc Set the suggestedJoin state and persist client-side.
* @param {{ orgId: string, orgName: string }} payload
* @returns {void}
*/
setSuggestedJoin(payload) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || typeof payload.orgId !== 'string' || !payload.orgId || typeof payload.orgName !== 'string' || !payload.orgName) return;
this.suggestedJoin = payload;
localStorage.setItem(`${config.cookie.prefix}SuggestedJoin`, JSON.stringify(payload));
},

/**
* @desc Dismiss the suggested-join banner: clear state and persistence.
* @returns {void}
*/
dismissSuggestedJoin() {
this.suggestedJoin = null;
localStorage.removeItem(`${config.cookie.prefix}SuggestedJoin`);
},

/**
* @desc Clear suggestedJoin if the user just joined the suggested org.
* @param {string} orgId - The org the user became a member of.
* @returns {void}
*/
clearSuggestedJoinIfMember(orgId) {
if (this.suggestedJoin?.orgId === orgId) {
this.dismissSuggestedJoin();
}
},

/**
* @desc Sign up a new user and update auth state.
* @param {Object} params - Signup payload (email, password, firstName, lastName)
Expand All @@ -176,6 +219,10 @@ export const useAuthStore = defineStore('auth', {
this.cookieExpire = res.data.tokenExpiresIn;
this.user = res.data.user;

if (res.data.suggestedJoin) {
this.setSuggestedJoin(res.data.suggestedJoin);
}

coreStore.refreshNav(this.isLoggedIn);
capture('signup_completed', { email: res.data.user.email });
identify(res.data.user.id || res.data.user._id, { email: res.data.user.email, plan: res.data.user.plan });
Expand Down Expand Up @@ -213,6 +260,7 @@ export const useAuthStore = defineStore('auth', {
this.cookieExpire = 0;
this.user = null;
this.pendingRequests = [];
this.suggestedJoin = null;

updateAbilities([]);

Expand All @@ -222,6 +270,7 @@ export const useAuthStore = defineStore('auth', {
localStorage.removeItem(`${config.cookie.prefix}UserRoles`);
localStorage.removeItem(`${config.cookie.prefix}CookieExpire`);
localStorage.removeItem(`${config.cookie.prefix}LastLoginAt`);
localStorage.removeItem(`${config.cookie.prefix}SuggestedJoin`);

coreStore.refreshNav(false);
},
Expand Down
195 changes: 195 additions & 0 deletions src/modules/auth/tests/auth.store.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,201 @@ describe('Auth Store', () => {
});
});

describe('suggestedJoin', () => {
it('should initialize suggestedJoin as null', () => {
const authStore = useAuthStore();
expect(authStore.suggestedJoin).toBe(null);
});

it('setSuggestedJoin sets state', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
expect(authStore.suggestedJoin).toEqual({ orgId: 'o1', orgName: 'Acme' });
});

it('dismissSuggestedJoin clears state', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
authStore.dismissSuggestedJoin();
expect(authStore.suggestedJoin).toBe(null);
});

it('dismissSuggestedJoin removes persisted localStorage key', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
authStore.dismissSuggestedJoin();
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('clearSuggestedJoinIfMember clears when orgId matches', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
authStore.clearSuggestedJoinIfMember('o1');
expect(authStore.suggestedJoin).toBe(null);
});

it('clearSuggestedJoinIfMember does NOT clear when orgId differs', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
authStore.clearSuggestedJoinIfMember('o2');
expect(authStore.suggestedJoin).toEqual({ orgId: 'o1', orgName: 'Acme' });
});

it('clearSuggestedJoinIfMember is a no-op when suggestedJoin is null', () => {
const authStore = useAuthStore();
expect(() => authStore.clearSuggestedJoinIfMember('o1')).not.toThrow();
expect(authStore.suggestedJoin).toBe(null);
});

it('signout clears suggestedJoin', async () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });

axios.post.mockResolvedValueOnce({ data: {} });
await authStore.signout();

expect(authStore.suggestedJoin).toBe(null);
});

it('signout removes suggestedJoin from localStorage', async () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });

axios.post.mockResolvedValueOnce({ data: {} });
await authStore.signout();

expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('signup with suggestedJoin in response sets state', async () => {
const authStore = useAuthStore();
const mockResponse = {
data: {
user: { id: '456', email: 'new@test.com', roles: ['user'] },
tokenExpiresIn: Date.now() + 3600000,
suggestedJoin: { orgId: 'org-x', orgName: 'Org X' },
},
};

axios.post.mockResolvedValueOnce(mockResponse);
await authStore.signup({ email: 'new@test.com', password: 'password123' });

expect(authStore.suggestedJoin).toEqual({ orgId: 'org-x', orgName: 'Org X' });
});

it('signup without suggestedJoin in response leaves state null', async () => {
const authStore = useAuthStore();
const mockResponse = {
data: {
user: { id: '456', email: 'new@test.com', roles: ['user'] },
tokenExpiresIn: Date.now() + 3600000,
},
};

axios.post.mockResolvedValueOnce(mockResponse);
await authStore.signup({ email: 'new@test.com', password: 'password123' });

expect(authStore.suggestedJoin).toBe(null);
});

it('signup with null suggestedJoin in response leaves state null', async () => {
const authStore = useAuthStore();
const mockResponse = {
data: {
user: { id: '456', email: 'new@test.com', roles: ['user'] },
tokenExpiresIn: Date.now() + 3600000,
suggestedJoin: null,
},
};

axios.post.mockResolvedValueOnce(mockResponse);
await authStore.signup({ email: 'new@test.com', password: 'password123' });

expect(authStore.suggestedJoin).toBe(null);
});

it('setSuggestedJoin persists to localStorage', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
const stored = JSON.parse(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`));
expect(stored).toEqual({ orgId: 'o1', orgName: 'Acme' });
});

it('initFromStorage restores suggestedJoin from localStorage', () => {
localStorage.setItem(`${config.cookie.prefix}SuggestedJoin`, JSON.stringify({ orgId: 'o1', orgName: 'Acme' }));
const authStore = useAuthStore();
authStore.initFromStorage();
expect(authStore.suggestedJoin).toEqual({ orgId: 'o1', orgName: 'Acme' });
});

it('initFromStorage does not throw on corrupt suggestedJoin, leaves state null, removes bad key', () => {
localStorage.setItem(`${config.cookie.prefix}SuggestedJoin`, '{not valid json{{');
const authStore = useAuthStore();
expect(() => authStore.initFromStorage()).not.toThrow();
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('setSuggestedJoin ignores non-object payloads and does not touch state or localStorage', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin('garbage');
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);

authStore.setSuggestedJoin(true);
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);

authStore.setSuggestedJoin(null);
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('setSuggestedJoin still works correctly for valid object payloads', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1', orgName: 'Acme' });
expect(authStore.suggestedJoin).toEqual({ orgId: 'o1', orgName: 'Acme' });
const stored = JSON.parse(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`));
expect(stored).toEqual({ orgId: 'o1', orgName: 'Acme' });
});

it('setSuggestedJoin ignores arrays even though they pass typeof object', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin(['o1', 'Acme']);
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('setSuggestedJoin ignores objects missing orgId or orgName string fields', () => {
const authStore = useAuthStore();
authStore.setSuggestedJoin({ orgId: 'o1' }); // missing orgName
expect(authStore.suggestedJoin).toBe(null);
authStore.setSuggestedJoin({ orgName: 'Acme' }); // missing orgId
expect(authStore.suggestedJoin).toBe(null);
authStore.setSuggestedJoin({ orgId: 42, orgName: 'Acme' }); // non-string orgId
expect(authStore.suggestedJoin).toBe(null);
authStore.setSuggestedJoin({ orgId: '', orgName: 'Acme' }); // empty orgId
expect(authStore.suggestedJoin).toBe(null);
});

it('initFromStorage drops malformed-but-parseable localStorage values (array/missing fields) and removes the key', () => {
// Valid JSON but wrong shape — should not restore state
localStorage.setItem(`${config.cookie.prefix}SuggestedJoin`, JSON.stringify(['o1', 'Acme']));
const authStore = useAuthStore();
authStore.initFromStorage();
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});

it('initFromStorage drops object missing required string fields and removes the key', () => {
localStorage.setItem(`${config.cookie.prefix}SuggestedJoin`, JSON.stringify({ orgId: 'o1' }));
const authStore = useAuthStore();
authStore.initFromStorage();
expect(authStore.suggestedJoin).toBe(null);
expect(localStorage.getItem(`${config.cookie.prefix}SuggestedJoin`)).toBe(null);
});
});

describe('resendVerification', () => {
it('should call resend-verification endpoint and return response data', async () => {
const authStore = useAuthStore();
Expand Down
Loading
Loading