diff --git a/src/modules/auth/tests/auth.signup.view.unit.tests.js b/src/modules/auth/tests/auth.signup.view.unit.tests.js index a93f8e36c..1dd951c92 100644 --- a/src/modules/auth/tests/auth.signup.view.unit.tests.js +++ b/src/modules/auth/tests/auth.signup.view.unit.tests.js @@ -199,6 +199,32 @@ describe('auth.signup.view', () => { await flushPromises(); expect(wrapper.vm.signupError).toBe('Password too weak Email invalid'); }); + + it('shows the API error description (the precise reason in the error envelope)', async () => { + signupMock.mockRejectedValueOnce({ + response: { status: 403, data: { description: 'Registration is currently deactivated' } }, + }); + const wrapper = mountView(); + await flushPromises(); + wrapper.vm.email = 'john@example.com'; + wrapper.vm.password = 'password123'; + await wrapper.vm.validate(); + await flushPromises(); + expect(wrapper.vm.signupError).toBe('Registration is currently deactivated'); + }); + + it('prefers description over message when both are present', async () => { + signupMock.mockRejectedValueOnce({ + response: { status: 403, data: { message: 'Forbidden', description: 'Registration is currently deactivated' } }, + }); + const wrapper = mountView(); + await flushPromises(); + wrapper.vm.email = 'john@example.com'; + wrapper.vm.password = 'password123'; + await wrapper.vm.validate(); + await flushPromises(); + expect(wrapper.vm.signupError).toBe('Registration is currently deactivated'); + }); }); describe('password visibility toggle', () => { diff --git a/src/modules/auth/views/signup.view.vue b/src/modules/auth/views/signup.view.vue index 5af00748f..a5efc9217 100644 --- a/src/modules/auth/views/signup.view.vue +++ b/src/modules/auth/views/signup.view.vue @@ -362,6 +362,9 @@ export default { signupErrorMessage(error) { const data = error?.response?.data; if (typeof data === 'string' && data.trim()) return data; + // The API error envelope carries the precise reason in `description` + // (e.g. signup disabled) while `message` is often generic — prefer it. + if (typeof data?.description === 'string' && data.description.trim()) return data.description; if (typeof data?.message === 'string' && data.message.trim()) return data.message; if (typeof data?.error === 'string' && data.error.trim()) return data.error; if (Array.isArray(data?.errors) && data.errors.length > 0) { 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 }} + + + + {{ statusChip(item).label }} + + - + + @@ -69,6 +83,7 @@ variant="text" size="small" color="error" + data-test="member-remove" @click="openRemoveDialog(item)" > @@ -80,10 +95,15 @@ - Remove Member + {{ removeDialog.pending ? 'Cancel Invitation' : 'Remove Member' }} - Are you sure you want to remove {{ removeDialog.memberName }} from this organization? + + Cancel this pending invitation? + + + Are you sure you want to remove {{ removeDialog.memberName }} from this organization? + @@ -269,6 +289,7 @@ export default { { text: 'Name', value: 'userId.firstName', kind: 'capitalize' }, { text: 'Email', value: 'userId.email', kind: 'email' }, { text: 'Org Role', value: 'role', kind: 'slot', slotName: 'role' }, + { text: 'Status', value: 'status', kind: 'slot', slotName: 'status' }, { text: 'Joined', value: 'createdAt', kind: 'date', format: 'DD/MM/YY' }, { text: 'Last Login', value: 'userId.lastLoginAt', kind: 'date', format: 'DD/MM/YY HH:mm' }, { text: 'Actions', value: 'actions', kind: 'slot', slotName: 'actions' }, @@ -289,6 +310,7 @@ export default { show: false, memberId: null, memberName: '', + pending: false, }, roleDialog: { show: false, @@ -354,6 +376,17 @@ export default { return user.email || 'Unknown'; }, roleColor, + /** + * @desc Status chip descriptor for a membership row. Pending owner_add rows + * (awaiting the invitee's consent) render as Invited; everything else + * is an active member. Mirrors the role-chip slot pattern. + * @param {Object} member - Membership row from the members list. + * @returns {{ label: string, color: string }} + */ + statusChip(member) { + if (member?.status === 'pending') return { label: 'Invited', color: 'warning' }; + return { label: 'Active', color: 'success' }; + }, /** * @desc Check whether the current user can update memberships in the viewed organization. * @returns {boolean} @@ -422,6 +455,7 @@ export default { show: true, memberId: member.id || member._id, memberName: this.memberName(member), + pending: member.status === 'pending', }; }, async confirmRemoveMember() { 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.members.component.unit.tests.js b/src/modules/organizations/tests/organizations.members.component.unit.tests.js index ee4719310..809630167 100644 --- a/src/modules/organizations/tests/organizations.members.component.unit.tests.js +++ b/src/modules/organizations/tests/organizations.members.component.unit.tests.js @@ -228,3 +228,72 @@ describe('organizations.members.component — add member', () => { expect(addMember).not.toHaveBeenCalled(); }); }); + +describe('organizations.members.component — pending status', () => { + beforeEach(() => { + setActivePinia(createPinia()); + abilityMock.can.mockReset(); + abilityMock.can.mockReturnValue(true); + abilityMock.rules = [{ action: 'create', subject: 'Membership' }]; + }); + + it('declares a Status column rendered as a slot', async () => { + const { wrapper } = await mountComponent(); + const status = wrapper.vm.headers.find((h) => h.value === 'status'); + expect(status).toEqual({ text: 'Status', value: 'status', kind: 'slot', slotName: 'status' }); + }); + + it('statusChip maps active → Active/success and pending → Invited/warning', async () => { + const { wrapper } = await mountComponent(); + expect(wrapper.vm.statusChip({ status: 'active' })).toEqual({ label: 'Active', color: 'success' }); + expect(wrapper.vm.statusChip({ status: 'pending' })).toEqual({ label: 'Invited', color: 'warning' }); + // Defensive: rows without a status field render as active members. + expect(wrapper.vm.statusChip({})).toEqual({ label: 'Active', color: 'success' }); + }); + + it('hides the role-change menu on pending rows but keeps the remove button (owner cancel affordance)', async () => { + const rowsStub = { + props: ['items'], + template: ` + + + + + `, + }; + const { useOrganizationsStore } = await import('../stores/organizations.store'); + const store = useOrganizationsStore(); + store.fetchMembers = vi.fn().mockResolvedValue([]); + store.members = [ + { id: 'm1', role: 'member', status: 'active', userId: { email: 'a@example.com' } }, + { id: 'm2', role: 'member', status: 'pending', userId: { email: 'b@example.com' } }, + ]; + const wrapper = shallowMount(OrganizationsMembersComponent, { + props: { organizationId: 'org1' }, + global: { + mocks: { config, $vuetify: { display: { smAndDown: false } } }, + stubs: { ...sharedStubs, coreDataTableComponent: rowsStub }, + }, + }); + const active = wrapper.find('[data-test="row-m1"]'); + const pending = wrapper.find('[data-test="row-m2"]'); + expect(active.text()).toContain('Active'); + expect(pending.text()).toContain('Invited'); + expect(active.find('[data-test="member-role-menu"]').exists()).toBe(true); + expect(pending.find('[data-test="member-role-menu"]').exists()).toBe(false); + expect(active.find('[data-test="member-remove"]').exists()).toBe(true); + expect(pending.find('[data-test="member-remove"]').exists()).toBe(true); + }); + + it('openRemoveDialog relabels the confirm copy for pending rows', async () => { + const { wrapper } = await mountComponent(); + wrapper.vm.openRemoveDialog({ id: 'm2', status: 'pending', userId: { email: 'b@example.com' } }); + expect(wrapper.vm.removeDialog.pending).toBe(true); + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain('Cancel this pending invitation?'); + wrapper.vm.openRemoveDialog({ id: 'm1', status: 'active', userId: { firstName: 'Jane' } }); + expect(wrapper.vm.removeDialog.pending).toBe(false); + await wrapper.vm.$nextTick(); + expect(wrapper.text()).toContain('Are you sure you want to remove'); + }); +}); diff --git a/src/modules/organizations/tests/organizations.required.view.unit.tests.js b/src/modules/organizations/tests/organizations.required.view.unit.tests.js index 5a12156d6..99bf541c9 100644 --- a/src/modules/organizations/tests/organizations.required.view.unit.tests.js +++ b/src/modules/organizations/tests/organizations.required.view.unit.tests.js @@ -6,6 +6,7 @@ import { createVuetify } from 'vuetify'; const resendVerificationMock = vi.hoisted(() => vi.fn().mockResolvedValue()); const refreshAbilitiesMock = vi.hoisted(() => vi.fn().mockResolvedValue()); const signoutMock = vi.hoisted(() => vi.fn().mockResolvedValue()); +const tokenMock = vi.hoisted(() => vi.fn().mockResolvedValue()); const authStoreMock = vi.hoisted(() => ({ isLoggedIn: true, user: null, @@ -14,6 +15,7 @@ const authStoreMock = vi.hoisted(() => ({ resendVerification: resendVerificationMock, refreshAbilities: refreshAbilitiesMock, signout: signoutMock, + token: tokenMock, })); vi.mock('../../auth/stores/auth.store', () => ({ @@ -22,11 +24,17 @@ vi.mock('../../auth/stores/auth.store', () => ({ const searchDomainMock = vi.hoisted(() => vi.fn().mockResolvedValue([])); const createJoinRequestMock = vi.hoisted(() => vi.fn().mockResolvedValue()); +const fetchMyPendingInvitationsMock = vi.hoisted(() => vi.fn().mockResolvedValue([])); +const acceptMembershipMock = vi.hoisted(() => vi.fn().mockResolvedValue({})); +const organizationsStoreMock = vi.hoisted(() => ({ + pendingInvitations: [], + searchOrganizationsByDomain: searchDomainMock, + createJoinRequest: createJoinRequestMock, + fetchMyPendingInvitations: fetchMyPendingInvitationsMock, + acceptMembership: acceptMembershipMock, +})); vi.mock('../stores/organizations.store', () => ({ - useOrganizationsStore: () => ({ - searchOrganizationsByDomain: searchDomainMock, - createJoinRequest: createJoinRequestMock, - }), + useOrganizationsStore: () => organizationsStoreMock, })); vi.mock('../../core/stores/core.store', () => ({ @@ -206,3 +214,100 @@ describe('organizations.required.view — D3 recovery copy', () => { expect(wrapper.text()).toContain('Check status'); }); }); + +describe('organizations.required.view — pending owner_add invitations', () => { + /** + * Mount the wall with an observable router push. + * @param {Function} [routerPush] - Spy for $router.push. + * @returns {import('@vue/test-utils').VueWrapper} mounted wrapper + */ + const mountWall = (routerPush = vi.fn()) => + mount(OrganizationsRequiredView, { + global: { + plugins: [createVuetify()], + mocks: { config: mockConfig, $route: { query: {} }, $router: { push: routerPush } }, + stubs: { RouterLink: true }, + }, + }); + + beforeEach(() => { + setActivePinia(createPinia()); + searchDomainMock.mockReset().mockResolvedValue([]); + fetchMyPendingInvitationsMock.mockReset().mockResolvedValue([]); + acceptMembershipMock.mockReset().mockResolvedValue({}); + refreshAbilitiesMock.mockReset().mockResolvedValue(); + tokenMock.mockReset().mockResolvedValue(); + organizationsStoreMock.pendingInvitations = []; + authStoreMock.user = { emailVerified: true, email: 'test@example.com' }; + authStoreMock.serverConfig = { mail: { configured: false } }; + authStoreMock.pendingRequests = []; + }); + + it('fetches pending invitations on created', async () => { + mountWall(); + await flushPromises(); + expect(fetchMyPendingInvitationsMock).toHaveBeenCalledTimes(1); + }); + + it('renders an invitation row with org name, role chip and Accept button', async () => { + organizationsStoreMock.pendingInvitations = [ + { id: 'inv1', role: 'admin', organizationId: { name: 'Acme Corp' } }, + ]; + const wrapper = mountWall(); + await flushPromises(); + const block = wrapper.find('[data-test="wall-pending-invitations"]'); + expect(block.exists()).toBe(true); + expect(block.text()).toContain('Acme Corp'); + expect(block.text()).toContain('admin'); + expect(wrapper.find('[data-test="wall-accept-invitation-inv1"]').exists()).toBe(true); + }); + + it('renders a See My Organizations link routing to /users/organizations', async () => { + organizationsStoreMock.pendingInvitations = [ + { id: 'inv1', role: 'member', organizationId: { name: 'Acme Corp' } }, + ]; + const routerPush = vi.fn(); + const wrapper = mountWall(routerPush); + await flushPromises(); + const link = wrapper.find('[data-test="wall-see-organizations"]'); + expect(link.exists()).toBe(true); + expect(wrapper.text()).toContain('See My Organizations'); + await link.trigger('click'); + expect(routerPush).toHaveBeenCalledWith('/users/organizations'); + }); + + it('hides the block when there are no pending invitations', async () => { + const wrapper = mountWall(); + await flushPromises(); + expect(wrapper.find('[data-test="wall-pending-invitations"]').exists()).toBe(false); + }); + + it('accept: accepts, soft-refreshes via token(), and redirects when currentOrganization appears', async () => { + organizationsStoreMock.pendingInvitations = [ + { id: 'inv1', role: 'member', organizationId: { name: 'Acme Corp' } }, + ]; + tokenMock.mockImplementation(async () => { + authStoreMock.user = { ...authStoreMock.user, currentOrganization: 'org1' }; + }); + const routerPush = vi.fn(); + const wrapper = mountWall(routerPush); + await flushPromises(); + await wrapper.vm.acceptInvitation({ id: 'inv1', role: 'member', organizationId: { name: 'Acme Corp' } }); + expect(acceptMembershipMock).toHaveBeenCalledWith('inv1'); + expect(tokenMock).toHaveBeenCalled(); + expect(routerPush).toHaveBeenCalledWith('/tasks'); + }); + + it('accept without a resulting currentOrganization refreshes the pending list and stays on the wall', async () => { + organizationsStoreMock.pendingInvitations = [ + { id: 'inv1', role: 'member', organizationId: { name: 'Acme Corp' } }, + ]; + const routerPush = vi.fn(); + const wrapper = mountWall(routerPush); + await flushPromises(); + fetchMyPendingInvitationsMock.mockClear(); + await wrapper.vm.acceptInvitation({ id: 'inv1', role: 'member', organizationId: { name: 'Acme Corp' } }); + expect(fetchMyPendingInvitationsMock).toHaveBeenCalled(); + expect(routerPush).not.toHaveBeenCalled(); + }); +}); 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(); diff --git a/src/modules/organizations/views/organizations.required.view.vue b/src/modules/organizations/views/organizations.required.view.vue index 30c5c4948..8ff2cf7c8 100644 --- a/src/modules/organizations/views/organizations.required.view.vue +++ b/src/modules/organizations/views/organizations.required.view.vue @@ -48,6 +48,46 @@ + + + You've been invited + + + + + + {{ inv.organizationId?.name || 'an organization' }} + + + + {{ inv.role || 'member' }} + + + Accept + + + + + + + See My Organizations + to manage your invitations. + + + Organizations matching your email domain @@ -114,6 +154,7 @@ import { useAuthStore } from '../../auth/stores/auth.store'; import { useCoreStore } from '../../core/stores/core.store'; import { useOrganizationsStore } from '../stores/organizations.store'; import orgAvatarComponent from '../../core/components/org.avatar.component.vue'; +import roleColor from '../../../lib/helpers/roleColor'; export default { name: 'OrganizationsRequiredView', @@ -126,6 +167,7 @@ export default { requestingOrgId: null, resending: false, resent: false, + acceptingId: null, }; }, computed: { @@ -149,17 +191,64 @@ export default { const authStore = useAuthStore(); return authStore.user?.email || ''; }, + /** + * @desc Pending owner_add invitations the user RECEIVED (durable surface — + * distinct from pendingRequests, the join requests the user SENT). + * @returns {Array} + */ + pendingInvitations() { + const organizationsStore = useOrganizationsStore(); + return organizationsStore.pendingInvitations || []; + }, }, async created() { - // Auto-load organizations matching the user's email domain const organizationsStore = useOrganizationsStore(); + // Auto-load organizations matching the user's email domain try { this.domainOrgs = await organizationsStore.searchOrganizationsByDomain(); } catch { this.domainOrgs = []; } + // Pending owner_add invitations: surfaced on the wall so an invited user + // is not stuck behind "No workspace found". + try { + await organizationsStore.fetchMyPendingInvitations(); + } catch { + // interceptor handles snackbar + } }, methods: { + roleColor, + /** + * @desc Accept a pending owner_add invitation from the wall. Mirrors the + * My Organizations accept flow (deliberate small duplication, not a + * new abstraction): accept server-side, refresh the pending list, + * then soft-refresh abilities via token() — refreshAbilities() signs + * out + throws on failure, which must never follow a successful + * accept. If the refreshed user now has a currentOrganization, leave + * the wall the same way refresh() does. + * @param {Object} invitation - The pending membership to accept. + * @returns {Promise} + */ + async acceptInvitation(invitation) { + const membershipId = invitation.id || invitation._id; + if (!membershipId || this.acceptingId) return; + this.acceptingId = membershipId; + const authStore = useAuthStore(); + const organizationsStore = useOrganizationsStore(); + try { + await organizationsStore.acceptMembership(membershipId); + await organizationsStore.fetchMyPendingInvitations(); + await authStore.token(); + if (authStore.user?.currentOrganization) { + this.$router.push(this.config.sign.route); + } + } catch { + // interceptor handles snackbar + } finally { + this.acceptingId = null; + } + }, async refresh() { const authStore = useAuthStore(); await authStore.refreshAbilities(); 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 @@ - - Accept - + + + Decline + + + Accept + + @@ -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.
+ See My Organizations + to manage your invitations. +