Skip to content

Commit 5d88766

Browse files
fix(auth,organizations): soft-refresh (token) at onboarding sites to prevent silent sign-out (#4431)
* fix(auth,organizations): soft-refresh (token) at onboarding sites to prevent silent sign-out refreshAbilities() signs the user out AND rethrows on any failure — correct for the axios 401 interceptor and router guard (session expiry), but a footgun at onboarding touchpoints where a transient network/5xx error silently ejects a brand-new user (worst at the final "Get Started" click). acceptInvitation already uses the safe token() soft-refresh for this reason; apply the same pattern to the remaining onboarding sites: - signup proceedToApp (final Get Started click) - verifyEmail post-verification redirect - organization.create (first / additional org create) - organizations.required refresh ("Check status") + requestToJoin token() refreshes abilities + user + nav via the same /token endpoint without signing out or throwing. The interceptor and router guard keep refreshAbilities() (signout-on-failure is intended there). Tests updated to mock/assert token(). Suite 145 files / 2454 tests + build green. Claude-Session: https://claude.ai/code/session_01XioM62H2iEMLsr686minjn * docs(organizations,auth): jsdoc on modified onboarding methods (coderabbit) Add/repair JSDoc headers per the coding guideline (every function documents @param + @returns; async returns Promise<void>): - signup proceedToApp @returns void -> Promise<void> - organizations.required refresh() + requestToJoin() headers - organization.create create() header Claude-Session: https://claude.ai/code/session_01XioM62H2iEMLsr686minjn
1 parent 9911328 commit 5d88766

7 files changed

Lines changed: 48 additions & 25 deletions

src/modules/auth/tests/auth.signup.view.unit.tests.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { createVuetify } from 'vuetify';
55

66
const signupMock = vi.hoisted(() => vi.fn());
77
const fetchServerConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue(null));
8-
const refreshAbilitiesMock = vi.hoisted(() => vi.fn().mockResolvedValue());
8+
const tokenMock = vi.hoisted(() => vi.fn().mockResolvedValue());
99
const resendVerificationMock = vi.hoisted(() => vi.fn().mockResolvedValue());
1010
const verifyInviteMock = vi.hoisted(() => vi.fn().mockResolvedValue({ valid: false, email: null }));
1111
vi.mock('../stores/auth.store', () => ({
12-
useAuthStore: () => ({ auth: false, signup: signupMock, serverConfig: null, fetchServerConfig: fetchServerConfigMock, refreshAbilities: refreshAbilitiesMock, resendVerification: resendVerificationMock, verifyInvite: verifyInviteMock }),
12+
useAuthStore: () => ({ auth: false, signup: signupMock, serverConfig: null, fetchServerConfig: fetchServerConfigMock, token: tokenMock, resendVerification: resendVerificationMock, verifyInvite: verifyInviteMock }),
1313
deduceNamesFromEmail: (email) => {
1414
const local = email ? email.split('@')[0] : '';
1515
const parts = local.split(/[._-]/);
@@ -66,7 +66,7 @@ describe('auth.signup.view', () => {
6666
setActivePinia(createPinia());
6767
signupMock.mockReset();
6868
fetchServerConfigMock.mockReset().mockResolvedValue(null);
69-
refreshAbilitiesMock.mockReset().mockResolvedValue();
69+
tokenMock.mockReset().mockResolvedValue();
7070
resendVerificationMock.mockReset().mockResolvedValue();
7171
verifyInviteMock.mockReset().mockResolvedValue({ valid: false, email: null });
7272
createOrganizationMock.mockReset();
@@ -343,7 +343,7 @@ describe('auth.signup.view', () => {
343343

344344
await wrapper.vm.proceedToApp();
345345

346-
expect(refreshAbilitiesMock).toHaveBeenCalled();
346+
expect(tokenMock).toHaveBeenCalled();
347347
expect(wrapper.vm.$router.push).toHaveBeenCalledWith('/tasks');
348348
});
349349

@@ -423,7 +423,7 @@ describe('auth.signup.view', () => {
423423
await wrapper.vm.proceedToApp();
424424
await flushPromises();
425425

426-
expect(refreshAbilitiesMock).toHaveBeenCalled();
426+
expect(tokenMock).toHaveBeenCalled();
427427
// useAuthStore mock has currentOrganization undefined → proceedToApp checks authStore.user.currentOrganization;
428428
// since orgs.enabled is true AND user has no currentOrganization, it goes to /organization-required, NOT redirect.
429429
// For this test, we assert the redirect-honor branch fires when user DOES have an org.

src/modules/auth/tests/auth.verifyEmail.view.unit.tests.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { createPinia, setActivePinia } from 'pinia';
44
import { createVuetify } from 'vuetify';
55

66
const verifyEmailMock = vi.hoisted(() => vi.fn());
7-
const refreshAbilitiesMock = vi.hoisted(() => vi.fn().mockResolvedValue());
7+
const tokenMock = vi.hoisted(() => vi.fn().mockResolvedValue());
88
const fetchServerConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue(null));
99
const storeMock = vi.hoisted(() => ({
1010
verifyEmail: verifyEmailMock,
11-
refreshAbilities: refreshAbilitiesMock,
11+
token: tokenMock,
1212
fetchServerConfig: fetchServerConfigMock,
1313
isLoggedIn: false,
1414
user: null,
@@ -48,7 +48,7 @@ describe('auth.verifyEmail.view', () => {
4848
beforeEach(() => {
4949
setActivePinia(createPinia());
5050
verifyEmailMock.mockReset();
51-
refreshAbilitiesMock.mockReset().mockResolvedValue();
51+
tokenMock.mockReset().mockResolvedValue();
5252
fetchServerConfigMock.mockReset().mockResolvedValue(null);
5353
storeMock.isLoggedIn = false;
5454
storeMock.user = null;

src/modules/auth/views/signup.view.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,12 +395,14 @@ export default {
395395
},
396396
/**
397397
* @desc Navigate to the main application after signup and org setup.
398-
* @returns {void}
398+
* @returns {Promise<void>}
399399
*/
400400
async proceedToApp() {
401-
// Refresh token/abilities after org setup to pick up new org context
401+
// Soft-refresh (token(), never throws) to pick up the new org context.
402+
// refreshAbilities() signs out + rethrows on any failure — at this final
403+
// onboarding click a transient error would silently eject a brand-new user.
402404
const authStore = useAuthStore();
403-
await authStore.refreshAbilities();
405+
await authStore.token();
404406
// If user has no org yet (pending join), go to organization-required page
405407
if (!authStore.user?.currentOrganization && this.serverConfig?.organizations?.enabled) {
406408
this.$router.push('/organization-required');

src/modules/auth/views/verifyEmail.view.vue

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,10 @@ export default {
103103
*/
104104
async handlePostVerificationRedirect(authStore) {
105105
if (authStore.isLoggedIn) {
106-
// Refresh user data to pick up emailVerified = true
107-
try {
108-
await authStore.refreshAbilities();
109-
} catch {
110-
// If refresh fails (e.g. expired token), stay on page
111-
return;
112-
}
106+
// Soft-refresh (token(), never throws) to pick up emailVerified = true.
107+
// refreshAbilities() signs out + rethrows on failure, which would eject
108+
// the user who just verified their email.
109+
await authStore.token();
113110
this.redirecting = true;
114111
const serverConfig = authStore.serverConfig || (await authStore.fetchServerConfig());
115112
if (!authStore.user?.currentOrganization && serverConfig?.organizations?.enabled) {

src/modules/organizations/tests/organization.create.view.unit.tests.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import { mount, flushPromises } from '@vue/test-utils';
33
import { createPinia, setActivePinia } from 'pinia';
44
import { createVuetify } from 'vuetify';
55

6-
const refreshAbilitiesMock = vi.hoisted(() => vi.fn().mockResolvedValue());
6+
const tokenMock = vi.hoisted(() => vi.fn().mockResolvedValue());
77
const createOrganizationMock = vi.hoisted(() => vi.fn().mockResolvedValue({ id: 'org-9' }));
8-
const authStoreMock = vi.hoisted(() => ({ user: null, refreshAbilities: refreshAbilitiesMock }));
8+
const authStoreMock = vi.hoisted(() => ({ user: null, token: tokenMock }));
99

1010
vi.mock('../../auth/stores/auth.store', () => ({
1111
useAuthStore: () => authStoreMock,
@@ -50,7 +50,7 @@ describe('organization.create.view — first-org redirect (#4422)', () => {
5050
beforeEach(() => {
5151
setActivePinia(createPinia());
5252
push.mockReset();
53-
refreshAbilitiesMock.mockReset().mockResolvedValue();
53+
tokenMock.mockReset().mockResolvedValue();
5454
createOrganizationMock.mockReset().mockResolvedValue({ id: 'org-9' });
5555
authStoreMock.user = null;
5656
});
@@ -62,7 +62,7 @@ describe('organization.create.view — first-org redirect (#4422)', () => {
6262
await wrapper.vm.create();
6363
await flushPromises();
6464
expect(createOrganizationMock).toHaveBeenCalledWith({ name: 'Acme', description: '' });
65-
expect(refreshAbilitiesMock).toHaveBeenCalled();
65+
expect(tokenMock).toHaveBeenCalled();
6666
expect(push).toHaveBeenCalledWith('/tasks');
6767
});
6868

src/modules/organizations/views/organization.create.view.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export default {
6464
};
6565
},
6666
methods: {
67+
/**
68+
* @desc Validate the form and create the organization, then route the user:
69+
* a first org (no current org yet) lands in the app via sign.route; an
70+
* additional org created while one is active goes to its detail page.
71+
* @returns {Promise<void>}
72+
*/
6773
async create() {
6874
if (this.loading) return;
6975
const form = await this.$refs.form.validate();
@@ -83,7 +89,10 @@ export default {
8389
description: this.description,
8490
});
8591
if (org) {
86-
await authStore.refreshAbilities();
92+
// Soft-refresh (token(), never throws) to pick up the new org context.
93+
// refreshAbilities() signs out + rethrows on failure, which would eject
94+
// a user who just created their org.
95+
await authStore.token();
8796
this.$router.push(
8897
isFirstOrg ? this.config.sign.route : `/users/organizations/${org.id || org._id}`,
8998
);

src/modules/organizations/views/organizations.required.view.vue

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,21 +249,36 @@ export default {
249249
this.acceptingId = null;
250250
}
251251
},
252+
/**
253+
* @desc Soft-refresh the session ("Check status") and, if the user now has an
254+
* organization, forward them into the app.
255+
* @returns {Promise<void>}
256+
*/
252257
async refresh() {
253258
const authStore = useAuthStore();
254-
await authStore.refreshAbilities();
259+
// Soft-refresh (token(), never throws) — refreshAbilities() signs out +
260+
// rethrows on failure, so a hiccup on "Check status" would eject the user.
261+
await authStore.token();
255262
if (authStore.user?.currentOrganization) {
256263
this.$router.push(this.config.sign.route);
257264
}
258265
},
266+
/**
267+
* @desc Send a join request for the given organization, then soft-refresh the
268+
* session so any resulting membership/pending state is reflected.
269+
* @param {Object} org - The organization to request to join.
270+
* @returns {Promise<void>}
271+
*/
259272
async requestToJoin(org) {
260273
const orgId = org.id || org._id;
261274
this.requestingOrgId = orgId;
262275
const organizationsStore = useOrganizationsStore();
263276
try {
264277
await organizationsStore.createJoinRequest(orgId);
265278
const authStore = useAuthStore();
266-
await authStore.refreshAbilities();
279+
// Soft-refresh (token(), never throws). refreshAbilities() would sign the
280+
// user out before this try/catch could swallow the throw.
281+
await authStore.token();
267282
} catch {
268283
// interceptor handles snackbar
269284
} finally {

0 commit comments

Comments
 (0)