From 7e7b356fb8016f6dbceae42303ed17b6fea239db Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 08:30:29 +0200 Subject: [PATCH 1/2] fix(invitations): inert referrals state when public signup is open The server never claims/finalizes an invite token while public signup is open, so the account Referrals tab solicited invites that could never convert. When the public auth config reports sign.up true the invite form is replaced by an informational alert; the My-referrals list stays visible read-only. Unknown/missing server config keeps the form (backend policy is the real gate). refs pierreb-devkit/Node#3833 --- .../invitations.account.view.unit.tests.js | 36 ++++++++ .../views/invitations.account.view.vue | 88 +++++++++++++------ 2 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/modules/invitations/tests/invitations.account.view.unit.tests.js b/src/modules/invitations/tests/invitations.account.view.unit.tests.js index 63cb501e0..b7227dea0 100644 --- a/src/modules/invitations/tests/invitations.account.view.unit.tests.js +++ b/src/modules/invitations/tests/invitations.account.view.unit.tests.js @@ -12,6 +12,11 @@ vi.mock('../../../lib/services/config', () => ({ }, })); +const authStoreMock = vi.hoisted(() => ({ serverConfig: null })); +vi.mock('../../auth/stores/auth.store', () => ({ + useAuthStore: () => authStoreMock, +})); + const stubs = { // Render the #status slot so the chip template function is invoked coreDataTableComponent: { @@ -44,6 +49,7 @@ const mountView = () => describe('invitations.account.view', () => { let store; beforeEach(() => { + authStoreMock.serverConfig = { sign: { in: true, up: false } }; setActivePinia(createPinia()); store = useInvitationsStore(); store.getInvitations = vi.fn().mockResolvedValue(); @@ -181,4 +187,34 @@ describe('invitations.account.view', () => { // Scaffold contract (#5 not built): the placeholder must never show numbers/fake math expect(placeholder.text()).not.toMatch(/\d/); }); + + it('signup open: replaces the invite form with the informational alert', () => { + authStoreMock.serverConfig = { sign: { in: true, up: true } }; + const wrapper = mountView(); + const alert = wrapper.find('[data-test="referrals-open-signup-alert"]'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain('Referral invitations are inactive on this deployment because public signup is open.'); + expect(wrapper.find('form').exists()).toBe(false); + }); + + it('signup closed: renders the invite form, no open-signup alert', () => { + const wrapper = mountView(); + expect(wrapper.find('[data-test="referrals-open-signup-alert"]').exists()).toBe(false); + expect(wrapper.find('form').exists()).toBe(true); + }); + + it('unknown server config (null) is treated as closed — form stays (backend CASL is the real gate)', () => { + authStoreMock.serverConfig = null; + const wrapper = mountView(); + expect(wrapper.vm.signupOpen).toBe(false); + expect(wrapper.find('form').exists()).toBe(true); + }); + + it('keeps the My referrals list visible read-only when signup is open', () => { + authStoreMock.serverConfig = { sign: { in: true, up: true } }; + store.invitations = [{ id: '1', usedAt: null, expiresAt: '2999-01-01' }]; + const wrapper = mountView(); + expect(wrapper.text()).toContain('My referrals'); + expect(wrapper.find('[data-test="referrals-summary"]').exists()).toBe(true); + }); }); diff --git a/src/modules/invitations/views/invitations.account.view.vue b/src/modules/invitations/views/invitations.account.view.vue index 9dab7e8d6..dda5bde88 100644 --- a/src/modules/invitations/views/invitations.account.view.vue +++ b/src/modules/invitations/views/invitations.account.view.vue @@ -23,35 +23,51 @@ Invite a contact -

- Share the platform with someone you know. They'll receive an email invitation to join. -

- -
- - - - Send invite - -
-
+ + + Referral invitations are inactive on this deployment because public signup is open. + + @@ -96,6 +112,7 @@ * Module dependencies. */ import { useInvitationsStore } from '../stores/invitations.store'; +import { useAuthStore } from '../../auth/stores/auth.store'; import { inviteStatus as deriveInviteStatus } from '../lib/invitations.status'; import coreDataTableComponent from '../../core/components/core.datatable.component.vue'; @@ -170,6 +187,19 @@ export default { { total: this.invitations.length, joined: 0, pending: 0, expired: 0, revoked: 0 }, ); }, + /** + * @desc Whether the deployment has PUBLIC signup open, from the server auth + * config (GET /api/auth/config → authStore.serverConfig.sign.up; loaded by the + * global router guard for any logged-in navigation). When open, the referral + * substrate is inert by design — a presented token is resolved but never + * claimed/finalized — so the invite form is replaced by an informational + * state. Strict === true: an unknown/missing serverConfig keeps the form + * (the backend policy remains the actual gate). + * @returns {boolean} + */ + signupOpen() { + return useAuthStore().serverConfig?.sign?.up === true; + }, }, methods: { /** From 34808cfca3771771c1beaeffff2bbb13d9f682e6 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 09:23:31 +0200 Subject: [PATCH 2/2] fix(invitations): clarify JSDoc strict-equality note in signupOpen computed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rephrase "Strict === true" (read as an undefined config flag) to explicit "Uses strict equality (=== true)" wording — no behaviour change. --- src/modules/invitations/views/invitations.account.view.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/invitations/views/invitations.account.view.vue b/src/modules/invitations/views/invitations.account.view.vue index dda5bde88..b75e3509e 100644 --- a/src/modules/invitations/views/invitations.account.view.vue +++ b/src/modules/invitations/views/invitations.account.view.vue @@ -193,8 +193,8 @@ export default { * global router guard for any logged-in navigation). When open, the referral * substrate is inert by design — a presented token is resolved but never * claimed/finalized — so the invite form is replaced by an informational - * state. Strict === true: an unknown/missing serverConfig keeps the form - * (the backend policy remains the actual gate). + * state. Uses strict equality (`=== true`) so an unknown/missing + * serverConfig keeps the form shown (the backend policy is the actual gate). * @returns {boolean} */ signupOpen() {