Skip to content

Commit d17a841

Browse files
feat(auth): invite-gated signup UI (#4212)
* feat(auth): signup relays inviteToken via query + verifyInvite action * feat(auth): signup view reveals form + prefills email on valid invite token When signup is disabled and ?inviteToken is present, verifyInvite() is called in created(); on valid invite the credentials form is shown and email prefilled. The token is passed through to authStore.signup() (omitted when null to keep existing tests exact-match on { email, password }). * chore(auth): verify fixups for invite signup UI Add missing verifyInvite error-path test (catch → {valid:false,email:null}) and invite-invalid view branch test to reach 100% line coverage on auth.store.js. * refactor(auth): drop dead params write in signup; align verifyInvite test mock with contract * fix(auth): normalize inviteToken query + suppress disabled-alert flash during invite verify - inviteToken: normalize string | string[] | undefined to single string or null - inviteChecking flag: true when token present, cleared after verifyInvite resolves - Suppresses disabled-alert flash while invite is being checked - Tests: 6 new cases (array/string/null normalization, inviteChecking lifecycle, no-flash)
1 parent e182512 commit d17a841

4 files changed

Lines changed: 193 additions & 12 deletions

File tree

src/modules/auth/stores/auth.store.js

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,19 @@ export const useAuthStore = defineStore('auth', {
203203
const api = `${config.api.protocol}://${config.api.host}:${config.api.port}/${config.api.base}`;
204204
const coreStore = useCoreStore();
205205

206+
const { inviteToken, ...payload } = params;
207+
206208
// Deduce firstName/lastName from email if not explicitly provided
207-
if (!params.firstName && !params.lastName) {
208-
const deduced = deduceNamesFromEmail(params.email);
209-
if (deduced.firstName) params.firstName = deduced.firstName;
210-
if (deduced.lastName) params.lastName = deduced.lastName;
209+
if (!payload.firstName && !payload.lastName) {
210+
const deduced = deduceNamesFromEmail(payload.email);
211+
if (deduced.firstName) { payload.firstName = deduced.firstName; }
212+
if (deduced.lastName) { payload.lastName = deduced.lastName; }
211213
}
212214

215+
const signupUrl = `${api}/${config.api.endPoints.auth}/signup${inviteToken ? `?inviteToken=${encodeURIComponent(inviteToken)}` : ''}`;
216+
213217
try {
214-
const res = await axios.post(`${api}/${config.api.endPoints.auth}/signup`, params);
218+
const res = await axios.post(signupUrl, payload);
215219
localStorage.setItem(`${config.cookie.prefix}UserRoles`, res.data.user.roles);
216220
localStorage.setItem(`${config.cookie.prefix}CookieExpire`, res.data.tokenExpiresIn);
217221

@@ -374,6 +378,22 @@ export const useAuthStore = defineStore('auth', {
374378
const res = await axios.post(`${api}/${config.api.endPoints.auth}/resend-verification`);
375379
return res.data;
376380
},
381+
382+
/**
383+
* @desc Verify a signup invite token (public). Used by the signup page to
384+
* reveal the form + prefill the invited email when public signup is closed.
385+
* @param {string} token
386+
* @returns {Promise<{valid: boolean, email: string|null}>}
387+
*/
388+
async verifyInvite(token) {
389+
const api = `${config.api.protocol}://${config.api.host}:${config.api.port}/${config.api.base}`;
390+
try {
391+
const res = await axios.get(`${api}/${config.api.endPoints.auth}/invitations/verify/${encodeURIComponent(token)}`);
392+
return res.data.data;
393+
} catch {
394+
return { valid: false, email: null };
395+
}
396+
},
377397
},
378398
});
379399

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

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ const signupMock = vi.hoisted(() => vi.fn());
77
const fetchServerConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue(null));
88
const refreshAbilitiesMock = vi.hoisted(() => vi.fn().mockResolvedValue());
99
const resendVerificationMock = vi.hoisted(() => vi.fn().mockResolvedValue());
10+
const verifyInviteMock = vi.hoisted(() => vi.fn().mockResolvedValue({ valid: false, email: null }));
1011
vi.mock('../stores/auth.store', () => ({
11-
useAuthStore: () => ({ auth: false, signup: signupMock, serverConfig: null, fetchServerConfig: fetchServerConfigMock, refreshAbilities: refreshAbilitiesMock, resendVerification: resendVerificationMock }),
12+
useAuthStore: () => ({ auth: false, signup: signupMock, serverConfig: null, fetchServerConfig: fetchServerConfigMock, refreshAbilities: refreshAbilitiesMock, resendVerification: resendVerificationMock, verifyInvite: verifyInviteMock }),
1213
deduceNamesFromEmail: (email) => {
1314
const local = email ? email.split('@')[0] : '';
1415
const parts = local.split(/[._-]/);
@@ -67,6 +68,7 @@ describe('auth.signup.view', () => {
6768
fetchServerConfigMock.mockReset().mockResolvedValue(null);
6869
refreshAbilitiesMock.mockReset().mockResolvedValue();
6970
resendVerificationMock.mockReset().mockResolvedValue();
71+
verifyInviteMock.mockReset().mockResolvedValue({ valid: false, email: null });
7072
createOrganizationMock.mockReset();
7173
});
7274

@@ -420,6 +422,119 @@ describe('auth.signup.view', () => {
420422
});
421423
});
422424

425+
/**
426+
* Extended mount helper for invite-gate scenarios.
427+
* Allows configuring $route.query, fetchServerConfig return, verifyInvite return, and signup mock.
428+
*/
429+
const mountSignup = ({ query = {}, serverConfig = null, verifyInvite = null, signup = undefined } = {}) => {
430+
fetchServerConfigMock.mockResolvedValue(serverConfig);
431+
verifyInviteMock.mockResolvedValue(verifyInvite);
432+
if (signup) signupMock.mockImplementation(signup);
433+
return mountView(makeFormStub(true), query);
434+
};
435+
436+
describe('inviteToken normalization', () => {
437+
test('when $route.query.inviteToken is an array, takes the first element', async () => {
438+
const wrapper = await mountSignup({
439+
query: { inviteToken: ['a', 'b'] },
440+
serverConfig: { sign: { up: true } },
441+
verifyInvite: { valid: true, email: null },
442+
});
443+
await flushPromises();
444+
expect(wrapper.vm.inviteToken).toBe('a');
445+
});
446+
447+
test('when $route.query.inviteToken is a string, uses it as-is', async () => {
448+
const wrapper = await mountSignup({
449+
query: { inviteToken: 'tok123' },
450+
serverConfig: { sign: { up: true } },
451+
verifyInvite: { valid: true, email: null },
452+
});
453+
await flushPromises();
454+
expect(wrapper.vm.inviteToken).toBe('tok123');
455+
});
456+
457+
test('when $route.query.inviteToken is absent, inviteToken is null', async () => {
458+
const wrapper = await mountSignup({ query: {}, serverConfig: { sign: { up: true } } });
459+
await flushPromises();
460+
expect(wrapper.vm.inviteToken).toBeNull();
461+
});
462+
463+
test('inviteChecking starts true when a token is present and becomes false after mount resolves', async () => {
464+
const wrapper = await mountSignup({
465+
query: { inviteToken: 'tok123' },
466+
serverConfig: { sign: { up: false } },
467+
verifyInvite: { valid: true, email: 'guest@example.com' },
468+
});
469+
// After flushPromises, created() has completed — inviteChecking must be false
470+
await flushPromises();
471+
expect(wrapper.vm.inviteChecking).toBe(false);
472+
});
473+
474+
test('inviteChecking starts false when no token is present', async () => {
475+
const wrapper = await mountSignup({ query: {}, serverConfig: { sign: { up: false } } });
476+
await flushPromises();
477+
expect(wrapper.vm.inviteChecking).toBe(false);
478+
});
479+
480+
test('disabled alert is hidden while inviteChecking is true (no flash)', async () => {
481+
// Simulate in-flight verify: mount without flushing promises
482+
verifyInviteMock.mockResolvedValue({ valid: false, email: null });
483+
fetchServerConfigMock.mockResolvedValue({ sign: { up: false } });
484+
const wrapper = mountView(makeFormStub(true), { inviteToken: 'pending-tok' });
485+
// Before promises flush, inviteChecking should be true → alert not shown
486+
expect(wrapper.vm.inviteChecking).toBe(true);
487+
await flushPromises();
488+
// After resolve, inviteChecking is false
489+
expect(wrapper.vm.inviteChecking).toBe(false);
490+
});
491+
});
492+
493+
describe('signup view — invited flow (signup disabled)', () => {
494+
test('with a valid ?inviteToken, the credentials form is shown and email is prefilled', async () => {
495+
const wrapper = await mountSignup({
496+
query: { inviteToken: 'tok123' },
497+
serverConfig: { sign: { up: false } },
498+
verifyInvite: { valid: true, email: 'guest@example.com' },
499+
});
500+
await flushPromises();
501+
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
502+
expect(wrapper.vm.email).toBe('guest@example.com');
503+
});
504+
505+
test('with no invite and signup disabled, the disabled alert is shown and no form', async () => {
506+
const wrapper = await mountSignup({ query: {}, serverConfig: { sign: { up: false } } });
507+
await flushPromises();
508+
expect(wrapper.text()).toContain('Registration is currently disabled');
509+
expect(wrapper.find('input[type="password"]').exists()).toBe(false);
510+
});
511+
512+
test('validate() passes inviteToken to store.signup', async () => {
513+
const signupSpy = vi.fn().mockResolvedValue({});
514+
const wrapper = await mountSignup({
515+
query: { inviteToken: 'tok123' },
516+
serverConfig: { sign: { up: false } },
517+
verifyInvite: { valid: true, email: 'guest@example.com' },
518+
signup: signupSpy,
519+
});
520+
await flushPromises();
521+
wrapper.vm.password = 'Sup3rStr0ng!';
522+
await wrapper.vm.validate();
523+
expect(signupSpy).toHaveBeenCalledWith(expect.objectContaining({ inviteToken: 'tok123', email: 'guest@example.com' }));
524+
});
525+
526+
test('with an invalid invite (valid: false) and signup disabled, the disabled alert is shown and no form', async () => {
527+
const wrapper = await mountSignup({
528+
query: { inviteToken: 'expired-token' },
529+
serverConfig: { sign: { up: false } },
530+
verifyInvite: { valid: false, email: null },
531+
});
532+
await flushPromises();
533+
expect(wrapper.text()).toContain('Registration is currently disabled');
534+
expect(wrapper.find('input[type="password"]').exists()).toBe(false);
535+
});
536+
});
537+
423538
describe('email verification flow', () => {
424539
it('shows email verification step when emailVerificationRequired is returned', async () => {
425540
signupMock.mockResolvedValueOnce({

src/modules/auth/tests/auth.store.unit.tests.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -639,11 +639,12 @@ describe('Auth Store', () => {
639639
};
640640

641641
axios.post.mockResolvedValueOnce(mockResponse);
642-
const params = { email: 'jane.smith@test.com', password: 'password123' };
643-
await authStore.signup(params);
642+
await authStore.signup({ email: 'jane.smith@test.com', password: 'password123' });
644643

645-
expect(params.firstName).toBe('Jane');
646-
expect(params.lastName).toBe('Smith');
644+
// Assert the meaningful thing: the POSTed body contains the deduced names
645+
const body = axios.post.mock.calls[0][1];
646+
expect(body.firstName).toBe('Jane');
647+
expect(body.lastName).toBe('Smith');
647648
});
648649
});
649650

@@ -880,6 +881,39 @@ describe('Auth Store', () => {
880881
});
881882
});
882883

884+
describe('auth store — invite token relay', () => {
885+
it('signup appends inviteToken as a query param and omits it from the body', async () => {
886+
const authStore = useAuthStore();
887+
axios.post.mockResolvedValueOnce({ data: { user: { id: '1', roles: ['user'], email: 'a@b.co' }, tokenExpiresIn: 10 } });
888+
await authStore.signup({ email: 'a@b.co', password: 'x', inviteToken: 'tok123' });
889+
const [url, body] = axios.post.mock.calls[0];
890+
expect(url).toContain('/signup?inviteToken=tok123');
891+
expect(body).not.toHaveProperty('inviteToken');
892+
});
893+
894+
it('signup without inviteToken posts to a plain /signup URL', async () => {
895+
const authStore = useAuthStore();
896+
axios.post.mockResolvedValueOnce({ data: { user: { id: '1', roles: ['user'], email: 'a@b.co' }, tokenExpiresIn: 10 } });
897+
await authStore.signup({ email: 'a@b.co', password: 'x' });
898+
expect(axios.post.mock.calls[0][0]).toMatch(/\/signup$/);
899+
});
900+
901+
it('verifyInvite returns { valid, email } from the API', async () => {
902+
const authStore = useAuthStore();
903+
axios.get.mockResolvedValueOnce({ data: { data: { valid: true, email: 'a@b.co' } } });
904+
const r = await authStore.verifyInvite('tok123');
905+
expect(axios.get.mock.calls[0][0]).toContain('/invitations/verify/tok123');
906+
expect(r).toEqual({ valid: true, email: 'a@b.co' });
907+
});
908+
909+
it('verifyInvite returns { valid: false, email: null } when the API rejects', async () => {
910+
const authStore = useAuthStore();
911+
axios.get.mockRejectedValueOnce(new Error('Not found'));
912+
const r = await authStore.verifyInvite('bad-token');
913+
expect(r).toEqual({ valid: false, email: null });
914+
});
915+
});
916+
883917
describe('PostHog analytics', () => {
884918
it('should call identify helper on signin with user data', async () => {
885919
const authStore = useAuthStore();

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
</div>
6363

6464
<!-- Registration disabled -->
65-
<v-alert v-if="serverConfig?.sign?.up === false" type="warning" variant="tonal" class="mb-4" :class="config.vuetify.theme.rounded">
65+
<v-alert v-if="serverConfig?.sign?.up === false && !invite?.valid && !inviteChecking" type="warning" variant="tonal" class="mb-4" :class="config.vuetify.theme.rounded">
6666
<span class="text-body-medium">Registration is currently disabled.</span>
6767
</v-alert>
6868

@@ -103,7 +103,10 @@
103103
</template>
104104

105105
<!-- Credentials form -->
106-
<template v-else-if="serverConfig === null || serverConfig?.sign?.up === true">
106+
<template v-else-if="serverConfig === null || serverConfig?.sign?.up === true || invite?.valid">
107+
<v-alert v-if="invite?.valid" type="success" variant="tonal" class="mb-4" :class="config.vuetify.theme.rounded">
108+
<span class="text-body-medium">You've been invited. Create your account below.</span>
109+
</v-alert>
107110
<v-alert
108111
v-if="signupError"
109112
type="error"
@@ -185,6 +188,9 @@ export default {
185188
theme,
186189
valid: false,
187190
serverConfig: undefined,
191+
inviteToken: (() => { const q = this.$route.query.inviteToken; return (Array.isArray(q) ? q[0] : q) || null; })(),
192+
inviteChecking: !!((() => { const q = this.$route.query.inviteToken; return (Array.isArray(q) ? q[0] : q) || null; })()),
193+
invite: null, // { valid, email } once verified
188194
signupStep: 'form',
189195
organizationWelcomeMessage: '',
190196
suggestedOrganization: null,
@@ -268,6 +274,11 @@ export default {
268274
async created() {
269275
const authStore = useAuthStore();
270276
this.serverConfig = await authStore.fetchServerConfig();
277+
if (this.inviteToken) {
278+
this.invite = await authStore.verifyInvite(this.inviteToken);
279+
if (this.invite?.valid && this.invite.email) this.email = this.invite.email;
280+
this.inviteChecking = false;
281+
}
271282
// If already logged in (e.g. page refresh after signup), redirect appropriately
272283
if (authStore.isLoggedIn) {
273284
if (authStore.user?.currentOrganization) {
@@ -303,6 +314,7 @@ export default {
303314
const result = await authStore.signup({
304315
email: this.email,
305316
password: this.password,
317+
...(this.inviteToken ? { inviteToken: this.inviteToken } : {}),
306318
});
307319
308320
if (!result) return;

0 commit comments

Comments
 (0)