From 960841a938983e8b335e84daecff993f2bd2e4fc Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:44:42 +0200 Subject: [PATCH 1/5] feat(organizations): add declineMembership store action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /membership-requests/:id — the invitee refuses a pending owner_add invitation and the row is dropped from the local pending list. DELETE is outside the snackbar interceptor methods, so the list refresh is the only success feedback by design. refs pierreb-devkit/Node#3831 --- .../stores/organizations.store.js | 19 +++++++++++++ .../tests/organizations.store.unit.tests.js | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/modules/organizations/stores/organizations.store.js b/src/modules/organizations/stores/organizations.store.js index 741b32cf7..a18b344df 100644 --- a/src/modules/organizations/stores/organizations.store.js +++ b/src/modules/organizations/stores/organizations.store.js @@ -373,6 +373,25 @@ export const useOrganizationsStore = defineStore('organizations', { ); return res.data.data; }, + + /** + * @desc Decline a pending owner_add invitation (the invited user refuses). + * The membership row is deleted server-side — the owner can re-invite + * later. Drops it from the local pending list on success. DELETE is not + * in the snackbar interceptor methods (post/put only), so the pending + * list refresh is the only success feedback — deliberate: declining is + * low-stakes and re-invitable. Errors still toast via the interceptor. + * @param {string} membershipId - The pending membership to decline + * @returns {Promise} The deleted membership + */ + async declineMembership(membershipId) { + const api = apiBase(); + const res = await axios.delete(`${api}/membership-requests/${membershipId}`); + this.pendingInvitations = this.pendingInvitations.filter( + (inv) => inv.id !== membershipId && inv._id !== membershipId, + ); + return res.data.data; + }, }, }); diff --git a/src/modules/organizations/tests/organizations.store.unit.tests.js b/src/modules/organizations/tests/organizations.store.unit.tests.js index 96e228909..802b8da8d 100644 --- a/src/modules/organizations/tests/organizations.store.unit.tests.js +++ b/src/modules/organizations/tests/organizations.store.unit.tests.js @@ -725,6 +725,33 @@ describe('Organizations Store', () => { }); }); + describe('declineMembership', () => { + it('should DELETE the pending membership, drop it locally, and return the deleted row', async () => { + const store = useOrganizationsStore(); + store.pendingInvitations = [{ id: 'inv1' }, { id: 'inv2' }]; + const declined = { id: 'inv1', status: 'pending' }; + + axios.delete.mockResolvedValueOnce({ data: { data: declined } }); + + const result = await store.declineMembership('inv1'); + + expect(axios.delete).toHaveBeenCalledWith(`${API}/membership-requests/inv1`); + expect(store.pendingInvitations).toEqual([{ id: 'inv2' }]); + expect(result).toEqual(declined); + }); + + it('should drop the invitation matched by _id', async () => { + const store = useOrganizationsStore(); + store.pendingInvitations = [{ _id: 'a' }, { _id: 'b' }]; + + axios.delete.mockResolvedValueOnce({ data: { data: { id: 'a' } } }); + + await store.declineMembership('a'); + + expect(store.pendingInvitations).toEqual([{ _id: 'b' }]); + }); + }); + describe('fetchAdminPendingRequests', () => { it('should aggregate pending requests across orgs', async () => { const store = useOrganizationsStore(); From 3e5a3d89dcca9da866a14a96d5a571367c9a7726 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:46:07 +0200 Subject: [PATCH 2/5] feat(users): decline button on pending invitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending owner_add list previously offered Accept only — an unwanted invitation sat forever. Decline opens a coreConfirmDialog (same pattern as Leave), deletes the membership, refreshes the pending list, and soft-refreshes abilities via token() (refreshAbilities signs out on failure). No success toast on DELETE by design — the row disappearing is the feedback. refs pierreb-devkit/Node#3831 --- .../user.organizations.view.unit.tests.js | 68 +++++++++++++- .../users/views/user.organizations.view.vue | 93 ++++++++++++++++--- 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/src/modules/users/tests/user.organizations.view.unit.tests.js b/src/modules/users/tests/user.organizations.view.unit.tests.js index 861fc9efd..2b92dc6bc 100644 --- a/src/modules/users/tests/user.organizations.view.unit.tests.js +++ b/src/modules/users/tests/user.organizations.view.unit.tests.js @@ -26,7 +26,7 @@ const sharedStubs = { 'v-col': { template: '
' }, 'v-card': { template: '
' }, 'v-list': { template: '
' }, - 'v-list-item': { template: '
' }, + 'v-list-item': { template: '
' }, 'v-list-item-title': { template: '
' }, 'v-list-item-subtitle': { template: '
' }, 'v-divider': { template: '
' }, @@ -265,3 +265,69 @@ describe('user.organizations.view — confirm dialog', () => { expect(tmpl).not.toMatch(/ { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + /** + * Mount the view with a store seeded with one pending invitation. + * @returns {Promise<{ wrapper: object, store: object, authStore: object }>} + */ + async function mountWithInvitation() { + const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); + const { useAuthStore } = await import('../../auth/stores/auth.store'); + const store = useOrganizationsStore(); + const authStore = useAuthStore(); + store.fetchOrganizations = vi.fn().mockResolvedValue([]); + store.fetchMyPendingInvitations = vi.fn().mockResolvedValue([]); + store.declineMembership = vi.fn().mockResolvedValue({ id: 'inv1' }); + authStore.token = vi.fn().mockResolvedValue(); + store.pendingInvitations = [{ id: 'inv1', role: 'member', organizationId: { name: 'Acme' } }]; + const wrapper = shallowMount(UserOrganizationsView, { + global: { mocks: sharedMocks(), stubs: sharedStubs }, + }); + return { wrapper, store, authStore }; + } + + test('renders a Decline button next to Accept for each pending invitation', async () => { + const { wrapper } = await mountWithInvitation(); + expect(wrapper.find('[data-test="decline-invitation-inv1"]').exists()).toBe(true); + expect(wrapper.find('[data-test="accept-invitation-inv1"]').exists()).toBe(true); + }); + + test('confirmDecline stores the invitation and opens the confirm dialog', async () => { + const { wrapper } = await mountWithInvitation(); + const inv = { id: 'inv1', role: 'member', organizationId: { name: 'Acme' } }; + wrapper.vm.confirmDecline(inv); + expect(wrapper.vm.invitationToDecline).toEqual(inv); + expect(wrapper.vm.declineDialog).toBe(true); + expect(wrapper.vm.declineInvitationMessage).toContain('Acme'); + }); + + test('declineInvitation declines via the store, refreshes the pending list, and soft-refreshes via token()', async () => { + const { wrapper, store, authStore } = await mountWithInvitation(); + store.fetchMyPendingInvitations.mockClear(); + wrapper.vm.confirmDecline({ id: 'inv1', role: 'member', organizationId: { name: 'Acme' } }); + await wrapper.vm.declineInvitation(); + // DELETE is not in the snackbar interceptor methods (post/put only): the + // pending-list refresh is deliberately the only success feedback. + expect(store.declineMembership).toHaveBeenCalledWith('inv1'); + expect(store.fetchMyPendingInvitations).toHaveBeenCalled(); + // Soft refresh (token) — refreshAbilities() signs out on failure, never use it here. + expect(authStore.token).toHaveBeenCalled(); + expect(wrapper.vm.declineDialog).toBe(false); + expect(wrapper.vm.decliningId).toBeNull(); + expect(wrapper.vm.invitationToDecline).toBeNull(); + }); + + test('declineInvitation: token() failure is non-fatal', async () => { + const { wrapper, store, authStore } = await mountWithInvitation(); + authStore.token = vi.fn().mockRejectedValue(new Error('token endpoint hiccup')); + wrapper.vm.confirmDecline({ id: 'inv1' }); + await expect(wrapper.vm.declineInvitation()).resolves.toBeUndefined(); + expect(store.declineMembership).toHaveBeenCalledWith('inv1'); + expect(wrapper.vm.decliningId).toBeNull(); + }); +}); diff --git a/src/modules/users/views/user.organizations.view.vue b/src/modules/users/views/user.organizations.view.vue index b6c98a59b..99fd8cea3 100644 --- a/src/modules/users/views/user.organizations.view.vue +++ b/src/modules/users/views/user.organizations.view.vue @@ -28,18 +28,32 @@ @@ -116,6 +130,15 @@ confirm-color="error" @confirm="leaveOrg" /> + + @@ -144,6 +167,9 @@ export default { leaveDialog: false, orgToLeave: null, acceptingId: null, + declineDialog: false, + invitationToDecline: null, + decliningId: null, }; }, computed: { @@ -176,6 +202,13 @@ export default { leaveOrgMessage() { return `Are you sure you want to leave ${this.orgToLeave?.name || ''}? You will lose access to all resources in this organization.`; }, + /** + * @desc Confirmation copy interpolated with the inviting org's name. + * @returns {string} + */ + declineInvitationMessage() { + return `Are you sure you want to decline the invitation to join ${this.invitationOrgName(this.invitationToDecline)}? The owner can invite you again later.`; + }, }, /** * @desc Fetch organizations on component creation so the list is populated @@ -249,6 +282,42 @@ export default { this.acceptingId = null; } }, + /** + * @desc Open the Decline confirmation dialog for a pending invitation. + * @param {Object} invitation - The pending membership to decline. + * @returns {void} + */ + confirmDecline(invitation) { + this.invitationToDecline = invitation; + this.declineDialog = true; + }, + /** + * @desc Decline the pending invitation selected in the dialog. DELETE is not + * in the snackbar interceptor methods (post/put only), so the pending + * list refresh below is the only success feedback — deliberate: + * declining is low-stakes and the owner can re-invite. Soft-refresh + * abilities via token(): refreshAbilities() signs out + throws on + * failure, which must never follow a successful decline (same choice + * as acceptInvitation above). + * @returns {Promise} + */ + async declineInvitation() { + const invitation = this.invitationToDecline; + const membershipId = invitation && (invitation.id || invitation._id); + if (!membershipId || this.decliningId) return; + this.decliningId = membershipId; + this.declineDialog = false; + try { + await this.organizationsStore.declineMembership(membershipId); + await this.organizationsStore.fetchMyPendingInvitations(); + await this.authStore.token(); + } catch { + // interceptor handles snackbar + } finally { + this.decliningId = null; + this.invitationToDecline = null; + } + }, /** * @desc Check whether the given org is the user's active organization. * @param {Object} org - Organization object. From ebdba421c2ca6dc382b3b4b172804c07b66b0e55 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sat, 13 Jun 2026 17:48:13 +0200 Subject: [PATCH 3/5] feat(organizations): status chips and invited-row handling in members The members endpoint now returns pending owner_add rows; without a Status column they were indistinguishable from active members. Adds an Active/Invited chip column, hides role-change on pending rows, and keeps remove as the owner's cancel affordance with relabeled confirm copy. refs pierreb-devkit/Node#3831 --- .../organizations.members.component.vue | 40 ++++++++++- ...anizations.members.component.unit.tests.js | 69 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/modules/organizations/components/organizations.members.component.vue b/src/modules/organizations/components/organizations.members.component.vue index c05eeb163..e178b9138 100644 --- a/src/modules/organizations/components/organizations.members.component.vue +++ b/src/modules/organizations/components/organizations.members.component.vue @@ -37,9 +37,22 @@ {{ item.role }} + +