Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/modules/auth/tests/auth.signup.view.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/modules/auth/views/signup.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,30 @@
{{ item.role }}
</v-chip>
</template>
<!-- Status column with chip: pending owner_add rows render as Invited -->
<template #status="{ item }">
<v-chip
:color="statusChip(item).color"
variant="tonal"
size="small"
>
{{ statusChip(item).label }}
</v-chip>
</template>
<!-- Actions column -->
<template #actions="{ item }">
<v-menu v-if="canUpdateMember()" location="bottom end">
<!-- Role change only makes sense once the invitee has accepted; the
remove button stays on pending rows — it is the owner's cancel
affordance for the invitation. -->
<v-menu v-if="canUpdateMember() && item.status !== 'pending'" location="bottom end">
<template #activator="{ props }">
<v-btn
v-bind="props"
icon
variant="text"
size="small"
class="mr-1"
data-test="member-role-menu"
>
<v-icon icon="fa-solid fa-user-pen" size="small"></v-icon>
</v-btn>
Expand All @@ -69,6 +83,7 @@
variant="text"
size="small"
color="error"
data-test="member-remove"
@click="openRemoveDialog(item)"
>
<v-icon icon="fa-solid fa-user-minus" size="small"></v-icon>
Expand All @@ -80,10 +95,15 @@
<v-dialog v-model="removeDialog.show" max-width="440">
<v-card :class="config.vuetify.theme.rounded" class="pa-4">
<v-card-title class="text-title-large font-weight-medium">
Remove Member
{{ removeDialog.pending ? 'Cancel Invitation' : 'Remove Member' }}
</v-card-title>
<v-card-text class="text-body-medium">
Are you sure you want to remove <strong>{{ removeDialog.memberName }}</strong> from this organization?
<template v-if="removeDialog.pending">
Cancel this pending invitation?
</template>
<template v-else>
Are you sure you want to remove <strong>{{ removeDialog.memberName }}</strong> from this organization?
</template>
Comment on lines 95 to +106
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
Expand Down Expand Up @@ -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' },
Expand All @@ -289,6 +310,7 @@ export default {
show: false,
memberId: null,
memberName: '',
pending: false,
},
roleDialog: {
show: false,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -422,6 +455,7 @@ export default {
show: true,
memberId: member.id || member._id,
memberName: this.memberName(member),
pending: member.status === 'pending',
};
},
async confirmRemoveMember() {
Expand Down
19 changes: 19 additions & 0 deletions src/modules/organizations/stores/organizations.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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;
},
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<div>
<div v-for="item in items" :key="item.id" :data-test="'row-' + item.id">
<slot name="status" :item="item" />
<slot name="actions" :item="item" />
</div>
</div>`,
};
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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,6 +15,7 @@ const authStoreMock = vi.hoisted(() => ({
resendVerification: resendVerificationMock,
refreshAbilities: refreshAbilitiesMock,
signout: signoutMock,
token: tokenMock,
}));

vi.mock('../../auth/stores/auth.store', () => ({
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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();
});
});
Loading
Loading