mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
fix(editor): Reintroduce user deletion actions in the members table in Users and Project settings page (#19604)
This commit is contained in:
@@ -3,7 +3,6 @@ import { MainSidebar } from './sidebar/main-sidebar';
|
|||||||
import { SettingsSidebar } from './sidebar/settings-sidebar';
|
import { SettingsSidebar } from './sidebar/settings-sidebar';
|
||||||
import { WorkflowPage } from './workflow';
|
import { WorkflowPage } from './workflow';
|
||||||
import { WorkflowsPage } from './workflows';
|
import { WorkflowsPage } from './workflows';
|
||||||
import { getVisiblePopper } from '../utils';
|
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const workflowsPage = new WorkflowsPage();
|
const workflowsPage = new WorkflowsPage();
|
||||||
@@ -62,8 +61,8 @@ export class SettingsUsersPage extends BasePage {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
opedDeleteDialog: (email: string) => {
|
opedDeleteDialog: (email: string) => {
|
||||||
this.getters.userRoleSelect(email).find('button').should('be.visible').click();
|
this.getters.userActionsToggle(email).should('be.visible').click();
|
||||||
getVisiblePopper().find('span').contains('Remove user').click();
|
this.getters.deleteUserAction().click();
|
||||||
this.getters.confirmDeleteModal().should('be.visible');
|
this.getters.confirmDeleteModal().should('be.visible');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { screen } from '@testing-library/vue';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { UserAction } from '@n8n/design-system';
|
||||||
|
import ProjectMembersActionsCell from '@/components/Projects/ProjectMembersActionsCell.vue';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import type { ProjectMemberData } from '@/types/projects.types';
|
||||||
|
|
||||||
|
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||||
|
const original = await importOriginal<object>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
N8nActionToggle: {
|
||||||
|
name: 'N8nActionToggle',
|
||||||
|
props: {
|
||||||
|
actions: { type: Array, required: true },
|
||||||
|
},
|
||||||
|
emits: ['action'],
|
||||||
|
template: `
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
v-for="a in actions"
|
||||||
|
:key="a.value"
|
||||||
|
:data-test-id="'action-' + a.value"
|
||||||
|
@click="$emit('action', a.value)"
|
||||||
|
>
|
||||||
|
{{ a.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMember: ProjectMemberData = {
|
||||||
|
id: '1',
|
||||||
|
firstName: 'John',
|
||||||
|
lastName: 'Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
role: 'project:editor',
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAction: UserAction<ProjectMemberData> = {
|
||||||
|
value: 'remove',
|
||||||
|
label: 'Remove user',
|
||||||
|
};
|
||||||
|
|
||||||
|
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||||
|
|
||||||
|
describe('ProjectMembersActionsCell', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
renderComponent = createComponentRenderer(ProjectMembersActionsCell, {
|
||||||
|
pinia: createTestingPinia(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions when allowed and emits on click', async () => {
|
||||||
|
const props = {
|
||||||
|
data: baseMember,
|
||||||
|
actions: [removeAction],
|
||||||
|
};
|
||||||
|
const { emitted } = renderComponent({ props });
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByTestId('action-remove'));
|
||||||
|
|
||||||
|
expect(emitted()).toHaveProperty('action');
|
||||||
|
expect(emitted().action[0]).toEqual([{ action: 'remove', userId: '1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when actions list is empty', () => {
|
||||||
|
const props = {
|
||||||
|
data: baseMember,
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
const { container } = renderComponent({ props });
|
||||||
|
expect(container.querySelector('button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visibility filtering is handled by ProjectMembersTable now
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ProjectMemberData } from '@/types/projects.types';
|
||||||
|
import type { UserAction } from '@n8n/design-system';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: ProjectMemberData;
|
||||||
|
actions: Array<UserAction<ProjectMemberData>>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
action: [value: { action: string; userId: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onAction = (action: string) => {
|
||||||
|
emit('action', { action, userId: props.data.id });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nActionToggle
|
||||||
|
v-if="props.actions.length > 0"
|
||||||
|
placement="bottom"
|
||||||
|
:actions="props.actions"
|
||||||
|
theme="dark"
|
||||||
|
@action="onAction"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -130,7 +130,6 @@ const mockActions: Array<ActionDropdownItem<string>> = [
|
|||||||
{ id: 'project:admin', label: 'Admin' },
|
{ id: 'project:admin', label: 'Admin' },
|
||||||
{ id: 'project:editor', label: 'Editor' },
|
{ id: 'project:editor', label: 'Editor' },
|
||||||
{ id: 'project:viewer', label: 'Viewer', disabled: true },
|
{ id: 'project:viewer', label: 'Viewer', disabled: true },
|
||||||
{ id: 'remove', label: 'Remove User', divided: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||||
@@ -224,11 +223,10 @@ describe('ProjectMembersRoleCell', () => {
|
|||||||
it('should pass actions to dropdown component', () => {
|
it('should pass actions to dropdown component', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
// Check that all action items are rendered
|
// Check that all role action items are rendered
|
||||||
expect(screen.getByTestId('action-project:admin')).toBeInTheDocument();
|
expect(screen.getByTestId('action-project:admin')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('action-project:editor')).toBeInTheDocument();
|
expect(screen.getByTestId('action-project:editor')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('action-project:viewer')).toBeInTheDocument();
|
expect(screen.getByTestId('action-project:viewer')).toBeInTheDocument();
|
||||||
expect(screen.getByTestId('action-remove')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,21 +246,6 @@ describe('ProjectMembersRoleCell', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit update:role when remove action is selected', async () => {
|
|
||||||
const { emitted } = renderComponent();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
await user.click(screen.getByTestId('action-remove'));
|
|
||||||
|
|
||||||
expect(emitted()).toHaveProperty('update:role');
|
|
||||||
expect(emitted()['update:role'][0]).toEqual([
|
|
||||||
{
|
|
||||||
role: 'remove',
|
|
||||||
userId: '123',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit with correct userId from data prop', async () => {
|
it('should emit with correct userId from data prop', async () => {
|
||||||
const customData = { ...mockMemberData, id: 'custom-user-id' };
|
const customData = { ...mockMemberData, id: 'custom-user-id' };
|
||||||
const { emitted } = renderComponent({
|
const { emitted } = renderComponent({
|
||||||
@@ -321,13 +304,6 @@ describe('ProjectMembersRoleCell', () => {
|
|||||||
expect(radioInputs).toHaveLength(3); // admin, editor, viewer (remove is not a radio)
|
expect(radioInputs).toHaveLength(3); // admin, editor, viewer (remove is not a radio)
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render remove option without radio button', () => {
|
|
||||||
renderComponent();
|
|
||||||
|
|
||||||
expect(screen.getByTestId('action-remove')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Remove User')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle disabled actions correctly', () => {
|
it('should handle disabled actions correctly', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import type { ProjectMemberData } from '@/types/projects.types';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: ProjectMemberData;
|
data: ProjectMemberData;
|
||||||
roles: Record<ProjectRole, { label: string; desc: string }>;
|
roles: Record<ProjectRole, { label: string; desc: string }>;
|
||||||
actions: Array<ActionDropdownItem<ProjectRole | 'remove'>>;
|
actions: Array<ActionDropdownItem<ProjectRole>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:role': [payload: { role: ProjectRole | 'remove'; userId: string }];
|
'update:role': [payload: { role: ProjectRole; userId: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedRole = ref<string>(props.data.role);
|
const selectedRole = ref<string>(props.data.role);
|
||||||
@@ -31,7 +31,7 @@ const roleLabel = computed(() =>
|
|||||||
: selectedRole.value,
|
: selectedRole.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onActionSelect = (role: ProjectRole | 'remove') => {
|
const onActionSelect = (role: ProjectRole) => {
|
||||||
emit('update:role', {
|
emit('update:role', {
|
||||||
role,
|
role,
|
||||||
userId: props.data.id,
|
userId: props.data.id,
|
||||||
@@ -54,11 +54,7 @@ const onActionSelect = (role: ProjectRole | 'remove') => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #menuItem="item">
|
<template #menuItem="item">
|
||||||
<N8nText v-if="item.id === 'remove'" color="text-dark" :class="$style.removeUser">{{
|
|
||||||
item.label
|
|
||||||
}}</N8nText>
|
|
||||||
<ElRadio
|
<ElRadio
|
||||||
v-else
|
|
||||||
:model-value="selectedRole"
|
:model-value="selectedRole"
|
||||||
:label="item.id"
|
:label="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
@@ -98,8 +94,5 @@ const onActionSelect = (role: ProjectRole | 'remove') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeUser {
|
/* removeUser style no longer used since remove moved to actions menu */
|
||||||
display: block;
|
|
||||||
padding: var(--spacing-2xs) var(--spacing-l);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ vi.mock('@/components/Projects/ProjectMembersRoleCell.vue', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock ProjectMembersActionsCell component to avoid dependence on design-system ActionToggle
|
||||||
|
vi.mock('@/components/Projects/ProjectMembersActionsCell.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'ProjectMembersActionsCell',
|
||||||
|
props: {
|
||||||
|
data: { type: Object, required: true },
|
||||||
|
actions: { type: Array, required: true },
|
||||||
|
},
|
||||||
|
emits: ['action'],
|
||||||
|
template: '<div :data-test-id="`actions-cell-` + data.id"></div>',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const mockMembers: ProjectMemberData[] = [
|
const mockMembers: ProjectMemberData[] = [
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
N8nDataTableServer,
|
N8nDataTableServer,
|
||||||
N8nText,
|
N8nText,
|
||||||
type ActionDropdownItem,
|
type ActionDropdownItem,
|
||||||
|
type UserAction,
|
||||||
} from '@n8n/design-system';
|
} from '@n8n/design-system';
|
||||||
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
||||||
import ProjectMembersRoleCell from '@/components/Projects/ProjectMembersRoleCell.vue';
|
import ProjectMembersRoleCell from '@/components/Projects/ProjectMembersRoleCell.vue';
|
||||||
|
import ProjectMembersActionsCell from '@/components/Projects/ProjectMembersActionsCell.vue';
|
||||||
import type { UsersInfoProps } from '@n8n/design-system/components/N8nUserInfo/UserInfo.vue';
|
import type { UsersInfoProps } from '@n8n/design-system/components/N8nUserInfo/UserInfo.vue';
|
||||||
import { isProjectRole } from '@/utils/typeGuards';
|
|
||||||
import type { ProjectMemberData } from '@/types/projects.types';
|
import type { ProjectMemberData } from '@/types/projects.types';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
@@ -21,11 +22,13 @@ const props = defineProps<{
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
projectRoles: Array<{ slug: string; displayName: string; licensed: boolean }>;
|
projectRoles: Array<{ slug: string; displayName: string; licensed: boolean }>;
|
||||||
|
actions?: Array<UserAction<ProjectMemberData>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:options': [payload: TableOptions];
|
'update:options': [payload: TableOptions];
|
||||||
'update:role': [payload: { role: ProjectRole | 'remove'; userId: string }];
|
'update:role': [payload: { role: ProjectRole; userId: string }];
|
||||||
|
action: [value: { action: string; userId: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const tableOptions = defineModel<TableOptions>('tableOptions', {
|
const tableOptions = defineModel<TableOptions>('tableOptions', {
|
||||||
@@ -50,6 +53,16 @@ const headers = ref<Array<TableHeader<ProjectMemberData>>>([
|
|||||||
key: 'role',
|
key: 'role',
|
||||||
disableSort: true,
|
disableSort: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
align: 'end',
|
||||||
|
width: 46,
|
||||||
|
disableSort: true,
|
||||||
|
value() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const roles = computed<Record<ProjectRole, { label: string; desc: string }>>(() => ({
|
const roles = computed<Record<ProjectRole, { label: string; desc: string }>>(() => ({
|
||||||
@@ -71,26 +84,23 @@ const roles = computed<Record<ProjectRole, { label: string; desc: string }>>(()
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const roleActions = computed<Array<ActionDropdownItem<ProjectRole | 'remove'>>>(() => [
|
const roleActions = computed<Array<ActionDropdownItem<ProjectRole>>>(() => [
|
||||||
...props.projectRoles.map((role) => ({
|
...props.projectRoles.map((role) => ({
|
||||||
id: role.slug as ProjectRole,
|
id: role.slug as ProjectRole,
|
||||||
label: role.displayName,
|
label: role.displayName,
|
||||||
disabled: !role.licensed,
|
disabled: !role.licensed,
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
id: 'remove',
|
|
||||||
label: i18n.baseText('projects.settings.table.row.removeUser'),
|
|
||||||
divided: true,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canUpdateRole = (member: ProjectMemberData): boolean => {
|
const canUpdateRole = (member: ProjectMemberData): boolean => member.id !== props.currentUserId;
|
||||||
// User cannot change their own role or remove themselves
|
|
||||||
return member.id !== props.currentUserId;
|
const onRoleChange = ({ role, userId }: { role: ProjectRole; userId: string }) => {
|
||||||
|
emit('update:role', { role, userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRoleChange = ({ role, userId }: { role: ProjectRole | 'remove'; userId: string }) => {
|
const filterActions = (member: ProjectMemberData) => {
|
||||||
emit('update:role', { role, userId });
|
if (member.id === props.currentUserId || member.role === 'project:personalOwner') return [];
|
||||||
|
return (props.actions ?? []).filter((action) => action.guard?.(member) ?? true);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -120,9 +130,14 @@ const onRoleChange = ({ role, userId }: { role: ProjectRole | 'remove'; userId:
|
|||||||
:actions="roleActions"
|
:actions="roleActions"
|
||||||
@update:role="onRoleChange"
|
@update:role="onRoleChange"
|
||||||
/>
|
/>
|
||||||
<N8nText v-else color="text-dark">{{
|
<N8nText v-else color="text-dark">{{ roles[item.role]?.label ?? item.role }}</N8nText>
|
||||||
isProjectRole(item.role) ? roles[item.role]?.label || item.role : item.role
|
</template>
|
||||||
}}</N8nText>
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<ProjectMembersActionsCell
|
||||||
|
:data="item"
|
||||||
|
:actions="filterActions(item)"
|
||||||
|
@action="$emit('action', $event)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</N8nDataTableServer>
|
</N8nDataTableServer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ describe('SettingsUsersActionsCell', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render action toggle for an owner', () => {
|
|
||||||
const props = { data: { ...baseUser, isOwner: true }, actions: mockActions };
|
|
||||||
const { container } = renderComponent({ props });
|
|
||||||
expect(container.firstChild).toBeEmptyDOMElement();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render action toggle if there are no actions', () => {
|
it('should not render action toggle if there are no actions', () => {
|
||||||
const props = { data: baseUser, actions: [] };
|
const props = { data: baseUser, actions: [] };
|
||||||
const { container } = renderComponent({ props });
|
const { container } = renderComponent({ props });
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const onUserAction = (action: string) => {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<N8nActionToggle
|
<N8nActionToggle
|
||||||
v-if="!props.data.isOwner && props.data.signInType !== 'ldap' && props.actions.length > 0"
|
v-if="props.data.signInType !== 'ldap' && props.actions.length > 0"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
:actions="props.actions"
|
:actions="props.actions"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
|
|||||||
@@ -55,10 +55,9 @@ const mockRoles = {
|
|||||||
[ROLE.Default]: { label: 'Default', desc: '' },
|
[ROLE.Default]: { label: 'Default', desc: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockActions: Array<ActionDropdownItem<Role | 'delete'>> = [
|
const mockActions: Array<ActionDropdownItem<Role>> = [
|
||||||
{ id: ROLE.Member, label: 'Member' },
|
{ id: ROLE.Member, label: 'Member' },
|
||||||
{ id: ROLE.Admin, label: 'Admin' },
|
{ id: ROLE.Admin, label: 'Admin' },
|
||||||
{ id: 'delete', label: 'Delete User', divided: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||||
@@ -106,14 +105,4 @@ describe('SettingsUsersRoleCell', () => {
|
|||||||
expect(emitted()).toHaveProperty('update:role');
|
expect(emitted()).toHaveProperty('update:role');
|
||||||
expect(emitted()['update:role'][0]).toEqual([{ role: ROLE.Admin, userId: '1' }]);
|
expect(emitted()['update:role'][0]).toEqual([{ role: ROLE.Admin, userId: '1' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit "update:role" with "delete" when delete action is clicked', async () => {
|
|
||||||
const { emitted } = renderComponent();
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
await user.click(screen.getByTestId('action-delete'));
|
|
||||||
|
|
||||||
expect(emitted()).toHaveProperty('update:role');
|
|
||||||
expect(emitted()['update:role'][0]).toEqual([{ role: 'delete', userId: '1' }]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import { type ActionDropdownItem, N8nActionDropdown, N8nIcon } from '@n8n/design
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: UsersList['items'][number];
|
data: UsersList['items'][number];
|
||||||
roles: Record<Role, { label: string; desc: string }>;
|
roles: Record<Role, { label: string; desc: string }>;
|
||||||
actions: Array<ActionDropdownItem<Role | 'delete'>>;
|
actions: Array<ActionDropdownItem<Role>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:role': [payload: { role: Role | 'delete'; userId: string }];
|
'update:role': [payload: { role: Role; userId: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const selectedRole = ref<Role>(props.data.role ?? ROLE.Default);
|
const selectedRole = ref<Role>(props.data.role ?? ROLE.Default);
|
||||||
const isEditable = computed(() => props.data.role !== ROLE.Owner);
|
const isEditable = computed(() => props.data.role !== ROLE.Owner);
|
||||||
const roleLabel = computed(() => props.roles[selectedRole.value].label);
|
const roleLabel = computed(() => props.roles[selectedRole.value].label);
|
||||||
|
|
||||||
const onActionSelect = (role: Role | 'delete') => {
|
const onActionSelect = (role: Role) => {
|
||||||
emit('update:role', {
|
emit('update:role', {
|
||||||
role,
|
role,
|
||||||
userId: props.data.id,
|
userId: props.data.id,
|
||||||
@@ -41,11 +41,7 @@ const onActionSelect = (role: Role | 'delete') => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template #menuItem="item">
|
<template #menuItem="item">
|
||||||
<N8nText v-if="item.id === 'delete'" color="text-dark" :class="$style.removeUser">{{
|
|
||||||
item.label
|
|
||||||
}}</N8nText>
|
|
||||||
<ElRadio
|
<ElRadio
|
||||||
v-else
|
|
||||||
:model-value="selectedRole"
|
:model-value="selectedRole"
|
||||||
:label="item.id"
|
:label="item.id"
|
||||||
@update:model-value="selectedRole = item.id as Role"
|
@update:model-value="selectedRole = item.id as Role"
|
||||||
|
|||||||
@@ -184,18 +184,6 @@ describe('SettingsUsersTable', () => {
|
|||||||
expect(emitted()['update:role'][0]).toEqual([{ role: 'global:admin', userId: '2' }]);
|
expect(emitted()['update:role'][0]).toEqual([{ role: 'global:admin', userId: '2' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit "action" with "delete" payload when delete is selected from role change', () => {
|
|
||||||
const { emitted } = renderComponent();
|
|
||||||
emitters.settingsUsersRoleCell.emit('update:role', { role: 'delete', userId: '2' });
|
|
||||||
|
|
||||||
// It should not emit 'update:role'
|
|
||||||
expect(emitted()).not.toHaveProperty('update:role');
|
|
||||||
|
|
||||||
// It should emit 'action'
|
|
||||||
expect(emitted()).toHaveProperty('action');
|
|
||||||
expect(emitted().action[0]).toEqual([{ action: 'delete', userId: '2' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render role as plain text when user lacks permission', () => {
|
it('should render role as plain text when user lacks permission', () => {
|
||||||
hasPermission.mockReturnValue(false);
|
hasPermission.mockReturnValue(false);
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ const roles = computed<Record<Role, { label: string; desc: string }>>(() => ({
|
|||||||
},
|
},
|
||||||
[ROLE.Default]: { label: i18n.baseText('auth.roles.default'), desc: '' },
|
[ROLE.Default]: { label: i18n.baseText('auth.roles.default'), desc: '' },
|
||||||
}));
|
}));
|
||||||
const roleActions = computed<Array<ActionDropdownItem<Role | 'delete'>>>(() => [
|
const roleActions = computed<Array<ActionDropdownItem<Role>>>(() => [
|
||||||
{
|
{
|
||||||
id: ROLE.Member,
|
id: ROLE.Member,
|
||||||
label: i18n.baseText('auth.roles.member'),
|
label: i18n.baseText('auth.roles.member'),
|
||||||
@@ -118,11 +118,6 @@ const roleActions = computed<Array<ActionDropdownItem<Role | 'delete'>>>(() => [
|
|||||||
id: ROLE.Admin,
|
id: ROLE.Admin,
|
||||||
label: i18n.baseText('auth.roles.admin'),
|
label: i18n.baseText('auth.roles.admin'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'delete',
|
|
||||||
label: i18n.baseText('settings.users.table.row.deleteUser'),
|
|
||||||
divided: true,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const canUpdateRole = computed((): boolean => {
|
const canUpdateRole = computed((): boolean => {
|
||||||
@@ -138,11 +133,7 @@ const filterActions = (user: UsersList['items'][number]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onRoleChange = ({ role, userId }: { role: string; userId: string }) => {
|
const onRoleChange = ({ role, userId }: { role: string; userId: string }) => {
|
||||||
if (role === 'delete') {
|
|
||||||
emit('action', { action: 'delete', userId });
|
|
||||||
} else {
|
|
||||||
emit('update:role', { role: role as Role, userId });
|
emit('update:role', { role: role as Role, userId });
|
||||||
}
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
127
packages/frontend/editor-ui/src/stores/projects.store.test.ts
Normal file
127
packages/frontend/editor-ui/src/stores/projects.store.test.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import * as projectsApi from '@/api/projects.api';
|
||||||
|
import type { Project, ProjectListItem } from '@/types/projects.types';
|
||||||
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import type { ProjectRole, Scope } from '@n8n/permissions';
|
||||||
|
|
||||||
|
// Minimal router mock to satisfy useRoute usage in the store
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => reactive({ params: {}, query: {}, path: '' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/api/projects.api', () => ({
|
||||||
|
updateProject: vi.fn(),
|
||||||
|
getProject: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Typed mocked facade for the API module
|
||||||
|
const mockedProjectsApi = vi.mocked(projectsApi);
|
||||||
|
|
||||||
|
describe('useProjectsStore.updateProject (partial payloads)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeStoreWithProject = () => {
|
||||||
|
const store = useProjectsStore();
|
||||||
|
// Seed myProjects and currentProject with proper typings
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const listItem: ProjectListItem = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'A',
|
||||||
|
description: 'desc',
|
||||||
|
icon: { type: 'icon', value: 'layers' },
|
||||||
|
type: ProjectTypes.Team,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
role: 'project:admin' as ProjectRole,
|
||||||
|
scopes: [] as Scope[],
|
||||||
|
};
|
||||||
|
store.myProjects = [listItem];
|
||||||
|
|
||||||
|
const project: Project = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'A',
|
||||||
|
description: 'desc',
|
||||||
|
icon: { type: 'icon', value: 'layers' },
|
||||||
|
type: ProjectTypes.Team,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
relations: [
|
||||||
|
{ id: 'u1', email: 'x@y.z', firstName: 'X', lastName: 'Y', role: 'project:editor' },
|
||||||
|
],
|
||||||
|
scopes: ['project:read' as Scope],
|
||||||
|
};
|
||||||
|
store.currentProject = project;
|
||||||
|
return store;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('updates name only when provided', async () => {
|
||||||
|
const store = makeStoreWithProject();
|
||||||
|
mockedProjectsApi.updateProject.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await store.updateProject('p1', { name: 'B' });
|
||||||
|
|
||||||
|
expect(mockedProjectsApi.updateProject).toHaveBeenCalledWith(expect.anything(), 'p1', {
|
||||||
|
name: 'B',
|
||||||
|
});
|
||||||
|
expect(store.myProjects[0].name).toBe('B');
|
||||||
|
expect(store.myProjects[0].description).toBe('desc');
|
||||||
|
expect(store.currentProject?.name).toBe('B');
|
||||||
|
expect(store.currentProject?.description).toBe('desc');
|
||||||
|
// No relations refetch
|
||||||
|
expect(mockedProjectsApi.getProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates description only when provided', async () => {
|
||||||
|
const store = makeStoreWithProject();
|
||||||
|
mockedProjectsApi.updateProject.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await store.updateProject('p1', { description: 'new-desc' });
|
||||||
|
|
||||||
|
expect(mockedProjectsApi.updateProject).toHaveBeenCalledWith(expect.anything(), 'p1', {
|
||||||
|
description: 'new-desc',
|
||||||
|
});
|
||||||
|
expect(store.myProjects[0].description).toBe('new-desc');
|
||||||
|
expect(store.myProjects[0].name).toBe('A');
|
||||||
|
expect(store.currentProject?.description).toBe('new-desc');
|
||||||
|
expect(store.currentProject?.name).toBe('A');
|
||||||
|
expect(mockedProjectsApi.getProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refetches project when relations are provided and does not touch name/icon/description', async () => {
|
||||||
|
const store = makeStoreWithProject();
|
||||||
|
mockedProjectsApi.updateProject.mockResolvedValue(undefined);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const serverProject: Project = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'SERVER',
|
||||||
|
description: 'SERVER',
|
||||||
|
icon: { type: 'icon', value: 'layers' },
|
||||||
|
type: ProjectTypes.Team,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
relations: [],
|
||||||
|
scopes: ['project:read' as Scope],
|
||||||
|
};
|
||||||
|
mockedProjectsApi.getProject.mockResolvedValue(serverProject);
|
||||||
|
|
||||||
|
await store.updateProject('p1', { relations: [{ userId: 'u2', role: 'project:viewer' }] });
|
||||||
|
|
||||||
|
// Ensure only relations were sent
|
||||||
|
expect(mockedProjectsApi.updateProject).toHaveBeenCalledWith(expect.anything(), 'p1', {
|
||||||
|
relations: [{ userId: 'u2', role: 'project:viewer' }],
|
||||||
|
});
|
||||||
|
// Refetch invoked
|
||||||
|
expect(mockedProjectsApi.getProject).toHaveBeenCalledWith(expect.anything(), 'p1');
|
||||||
|
// Local name/description remain unchanged eagerly; currentProject then replaced by getProject
|
||||||
|
expect(store.myProjects[0].name).toBe('A');
|
||||||
|
expect(store.myProjects[0].description).toBe('desc');
|
||||||
|
expect(store.currentProject?.name).toBe('SERVER');
|
||||||
|
expect(store.currentProject?.description).toBe('SERVER');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,6 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { getResourcePermissions } from '@n8n/permissions';
|
import { getResourcePermissions } from '@n8n/permissions';
|
||||||
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
|
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import type { IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
|
||||||
|
|
||||||
export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -118,24 +117,22 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||||||
return newProject;
|
return newProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProject = async (
|
const updateProject = async (id: Project['id'], projectData: UpdateProjectDto): Promise<void> => {
|
||||||
id: Project['id'],
|
|
||||||
projectData: Required<UpdateProjectDto>,
|
|
||||||
): Promise<void> => {
|
|
||||||
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
|
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
|
||||||
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
|
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
|
||||||
const { name, icon, description } = projectData;
|
const { name, icon, description, relations } = projectData;
|
||||||
if (projectIndex !== -1) {
|
if (projectIndex !== -1) {
|
||||||
myProjects.value[projectIndex].name = name;
|
if (typeof name !== 'undefined') myProjects.value[projectIndex].name = name;
|
||||||
myProjects.value[projectIndex].icon = icon as IconOrEmoji;
|
if (typeof icon !== 'undefined') myProjects.value[projectIndex].icon = icon;
|
||||||
|
if (typeof description !== 'undefined')
|
||||||
myProjects.value[projectIndex].description = description;
|
myProjects.value[projectIndex].description = description;
|
||||||
}
|
}
|
||||||
if (currentProject.value) {
|
if (currentProject.value) {
|
||||||
currentProject.value.name = name;
|
if (typeof name !== 'undefined') currentProject.value.name = name;
|
||||||
currentProject.value.icon = icon as IconOrEmoji;
|
if (typeof icon !== 'undefined') currentProject.value.icon = icon;
|
||||||
currentProject.value.description = description;
|
if (typeof description !== 'undefined') currentProject.value.description = description;
|
||||||
}
|
}
|
||||||
if (projectData.relations) {
|
if (relations) {
|
||||||
await getProject(id);
|
await getProject(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ vi.mock('@/components/Projects/ProjectMembersTable.vue', () => ({
|
|||||||
data: { type: Object, required: true },
|
data: { type: Object, required: true },
|
||||||
currentUserId: { type: String, required: false },
|
currentUserId: { type: String, required: false },
|
||||||
projectRoles: { type: Array, required: true },
|
projectRoles: { type: Array, required: true },
|
||||||
|
actions: { type: Array, required: false },
|
||||||
},
|
},
|
||||||
emits: ['update:options', 'update:role'],
|
emits: ['update:options', 'update:role', 'action'],
|
||||||
setup(_, { emit }) {
|
setup(_, { emit }) {
|
||||||
addEmitter('projectMembersTable', emit as unknown as Emitter);
|
addEmitter('projectMembersTable', emit as unknown as Emitter);
|
||||||
return {};
|
return {};
|
||||||
@@ -491,27 +492,31 @@ describe('ProjectSettings', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks member for removal, filters it out, and saves without it with telemetry', async () => {
|
it('removes member immediately and shows success toast with telemetry', async () => {
|
||||||
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
|
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
|
||||||
const { getByTestId, queryByTestId } = renderComponent();
|
const { getByTestId, queryByTestId } = renderComponent();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(getByTestId('members-count').textContent).toBe('1');
|
expect(getByTestId('members-count').textContent).toBe('1');
|
||||||
|
|
||||||
// Mark for removal
|
// Remove member via inline action
|
||||||
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'remove' });
|
emitters.projectMembersTable.emit('action', { action: 'remove', userId: '1' });
|
||||||
await nextTick();
|
await nextTick();
|
||||||
// Pending removal should hide members table container entirely
|
|
||||||
expect(queryByTestId('members-count')).toBeNull();
|
|
||||||
|
|
||||||
await userEvent.click(getByTestId('project-settings-save-button'));
|
// Members list container should now be hidden
|
||||||
await nextTick();
|
expect(queryByTestId('members-count')).toBeNull();
|
||||||
expect(updateSpy).toHaveBeenCalled();
|
expect(updateSpy).toHaveBeenCalled();
|
||||||
const payload = updateSpy.mock.calls[0][1];
|
const payload = updateSpy.mock.calls[0][1];
|
||||||
expect(payload.relations).toEqual([]);
|
expect(payload.relations).toEqual([]);
|
||||||
|
expect(mockShowMessage).toHaveBeenCalled();
|
||||||
expect(mockTrack).toHaveBeenCalledWith(
|
expect(mockTrack).toHaveBeenCalledWith(
|
||||||
'User removed member from project',
|
'User removed member from project',
|
||||||
expect.objectContaining({ project_id: '123', target_user_id: '1' }),
|
expect.objectContaining({ project_id: '123', target_user_id: '1' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Save should not re-add removed user
|
||||||
|
await userEvent.click(getByTestId('project-settings-save-button'));
|
||||||
|
await nextTick();
|
||||||
|
expect(queryByTestId('members-count')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents saving when invalid role selected', async () => {
|
it('prevents saving when invalid role selected', async () => {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
|||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
import { isIconOrEmoji, type IconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
||||||
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
||||||
|
import type { UserAction } from '@n8n/design-system';
|
||||||
|
import type { ProjectMemberData } from '@/types/projects.types';
|
||||||
import { isProjectRole } from '@/utils/typeGuards';
|
import { isProjectRole } from '@/utils/typeGuards';
|
||||||
|
|
||||||
type FormDataDiff = {
|
type FormDataDiff = {
|
||||||
@@ -53,6 +55,8 @@ const formData = ref<Pick<Project, 'name' | 'description' | 'relations'>>({
|
|||||||
description: '',
|
description: '',
|
||||||
relations: [],
|
relations: [],
|
||||||
});
|
});
|
||||||
|
// Used to skip one watcher sync after targeted server updates (e.g., immediate removal)
|
||||||
|
const suppressNextSync = ref(false);
|
||||||
|
|
||||||
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||||
'project:viewer': i18n.baseText('projects.settings.role.viewer'),
|
'project:viewer': i18n.baseText('projects.settings.role.viewer'),
|
||||||
@@ -77,8 +81,6 @@ const membersTableState = ref<TableOptions>({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingRemovals = ref<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const usersList = computed(() =>
|
const usersList = computed(() =>
|
||||||
usersStore.allUsers.filter((user: IUser) => {
|
usersStore.allUsers.filter((user: IUser) => {
|
||||||
const isAlreadySharedWithUser = (formData.value.relations || []).find(
|
const isAlreadySharedWithUser = (formData.value.relations || []).find(
|
||||||
@@ -102,6 +104,15 @@ const projectRoles = computed(() =>
|
|||||||
);
|
);
|
||||||
const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.slug);
|
const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.slug);
|
||||||
|
|
||||||
|
const projectMembersActions = computed<Array<UserAction<ProjectMemberData>>>(() => [
|
||||||
|
{
|
||||||
|
label: i18n.baseText('projects.settings.table.row.removeUser'),
|
||||||
|
value: 'remove',
|
||||||
|
guard: (member) =>
|
||||||
|
member.id !== usersStore.currentUser?.id && member.role !== 'project:personalOwner',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const onAddMember = (userId: string) => {
|
const onAddMember = (userId: string) => {
|
||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
const user = usersStore.usersById[userId];
|
const user = usersStore.usersById[userId];
|
||||||
@@ -117,20 +128,7 @@ const onAddMember = (userId: string) => {
|
|||||||
formData.value.relations.push(relation);
|
formData.value.relations.push(relation);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpdateMemberRole = async ({
|
const onUpdateMemberRole = async ({ userId, role }: { userId: string; role: ProjectRole }) => {
|
||||||
userId,
|
|
||||||
role,
|
|
||||||
}: {
|
|
||||||
userId: string;
|
|
||||||
role: ProjectRole | 'remove';
|
|
||||||
}) => {
|
|
||||||
if (role === 'remove') {
|
|
||||||
// Mark for pending removal instead of immediate removal
|
|
||||||
pendingRemovals.value.add(userId);
|
|
||||||
isDirty.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!projectsStore.currentProject) {
|
if (!projectsStore.currentProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,9 +146,6 @@ const onUpdateMemberRole = async ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
||||||
name: formData.value.name ?? '',
|
|
||||||
icon: projectIcon.value,
|
|
||||||
description: formData.value.description ?? '',
|
|
||||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||||
userId: r.id,
|
userId: r.id,
|
||||||
role: r.role,
|
role: r.role,
|
||||||
@@ -177,13 +172,59 @@ const onTextInput = () => {
|
|||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function onRemoveMember(userId: string) {
|
||||||
|
const current = projectsStore.currentProject;
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
const idx = formData.value.relations.findIndex((r) => r.id === userId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
// Optimistically remove from UI
|
||||||
|
const removed = formData.value.relations.splice(idx, 1)[0];
|
||||||
|
|
||||||
|
// Only persist if user existed in server relations
|
||||||
|
const isPersisted = current.relations.some((r) => r.id === userId);
|
||||||
|
if (!isPersisted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prevent next sync from wiping unsaved edits
|
||||||
|
suppressNextSync.value = true;
|
||||||
|
await projectsStore.updateProject(current.id, {
|
||||||
|
relations: current.relations
|
||||||
|
.filter((r) => r.id !== userId)
|
||||||
|
.map((r) => ({ userId: r.id, role: r.role })),
|
||||||
|
});
|
||||||
|
toast.showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.baseText('projects.settings.member.removed.title'),
|
||||||
|
});
|
||||||
|
telemetry.track('User removed member from project', {
|
||||||
|
project_id: current.id,
|
||||||
|
target_user_id: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
formData.value.relations.splice(idx, 0, removed);
|
||||||
|
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMembersListAction = async ({ action, userId }: { action: string; userId: string }) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'remove':
|
||||||
|
await onRemoveMember(userId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// no-op for now; future actions can be added here
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
formData.value.relations = projectsStore.currentProject?.relations
|
formData.value.relations = projectsStore.currentProject?.relations
|
||||||
? deepCopy(projectsStore.currentProject.relations)
|
? deepCopy(projectsStore.currentProject.relations)
|
||||||
: [];
|
: [];
|
||||||
formData.value.name = projectsStore.currentProject?.name ?? '';
|
formData.value.name = projectsStore.currentProject?.name ?? '';
|
||||||
formData.value.description = projectsStore.currentProject?.description ?? '';
|
formData.value.description = projectsStore.currentProject?.description ?? '';
|
||||||
pendingRemovals.value.clear();
|
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,24 +306,15 @@ const updateProject = async () => {
|
|||||||
throw new Error('Invalid role selected for this project.');
|
throw new Error('Invalid role selected for this project.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove pending removal members from relations before saving
|
|
||||||
const relationsToSave = formData.value.relations.filter(
|
|
||||||
(r: ProjectRelation) => !pendingRemovals.value.has(r.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
||||||
name: formData.value.name ?? '',
|
name: formData.value.name ?? '',
|
||||||
icon: projectIcon.value,
|
icon: projectIcon.value,
|
||||||
description: formData.value.description ?? '',
|
description: formData.value.description ?? '',
|
||||||
relations: relationsToSave.map((r: ProjectRelation) => ({
|
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||||
userId: r.id,
|
userId: r.id,
|
||||||
role: r.role,
|
role: r.role,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// After successful save, actually remove pending members from formData
|
|
||||||
formData.value.relations = relationsToSave;
|
|
||||||
pendingRemovals.value.clear();
|
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
|
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
|
||||||
@@ -356,9 +388,14 @@ const onIconUpdated = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip one sync after targeted updates (e.g. removal) to preserve unsaved edits
|
||||||
watch(
|
watch(
|
||||||
() => projectsStore.currentProject,
|
() => projectsStore.currentProject,
|
||||||
async () => {
|
async () => {
|
||||||
|
if (suppressNextSync.value) {
|
||||||
|
suppressNextSync.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
formData.value.name = projectsStore.currentProject?.name ?? '';
|
formData.value.name = projectsStore.currentProject?.name ?? '';
|
||||||
formData.value.description = projectsStore.currentProject?.description ?? '';
|
formData.value.description = projectsStore.currentProject?.description ?? '';
|
||||||
formData.value.relations = projectsStore.currentProject?.relations
|
formData.value.relations = projectsStore.currentProject?.relations
|
||||||
@@ -375,11 +412,8 @@ watch(
|
|||||||
|
|
||||||
// Add users property to the relation objects,
|
// Add users property to the relation objects,
|
||||||
// So that the table has access to the full user data
|
// So that the table has access to the full user data
|
||||||
// Filter out users marked for pending removal
|
|
||||||
const relationUsers = computed(() =>
|
const relationUsers = computed(() =>
|
||||||
formData.value.relations
|
formData.value.relations.map((relation: ProjectRelation) => {
|
||||||
.filter((relation: ProjectRelation) => !pendingRemovals.value.has(relation.id))
|
|
||||||
.map((relation: ProjectRelation) => {
|
|
||||||
const user = usersStore.usersById[relation.id];
|
const user = usersStore.usersById[relation.id];
|
||||||
// Ensure type safety for UI display while preserving original role in formData
|
// Ensure type safety for UI display while preserving original role in formData
|
||||||
const safeRole: ProjectRole = isProjectRole(relation.role) ? relation.role : 'project:viewer';
|
const safeRole: ProjectRole = isProjectRole(relation.role) ? relation.role : 'project:viewer';
|
||||||
@@ -526,8 +560,10 @@ onMounted(() => {
|
|||||||
:data="filteredMembersData"
|
:data="filteredMembersData"
|
||||||
:current-user-id="usersStore.currentUser?.id"
|
:current-user-id="usersStore.currentUser?.id"
|
||||||
:project-roles="projectRoles"
|
:project-roles="projectRoles"
|
||||||
|
:actions="projectMembersActions"
|
||||||
@update:options="onUpdateMembersTableOptions"
|
@update:options="onUpdateMembersTableOptions"
|
||||||
@update:role="onUpdateMemberRole"
|
@update:role="onUpdateMemberRole"
|
||||||
|
@action="onMembersListAction"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -18,16 +18,32 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useSSOStore } from '@/stores/sso.store';
|
import { useSSOStore } from '@/stores/sso.store';
|
||||||
|
import * as permissions from '@/utils/rbac/permissions';
|
||||||
|
|
||||||
const { emitters, addEmitter } = useEmitters<'settingsUsersTable'>();
|
const { emitters, addEmitter } = useEmitters<'settingsUsersTable'>();
|
||||||
|
|
||||||
// Mock the SettingsUsersTable component to emit events
|
// Mock the SettingsUsersTable component to emit events and render an accessible actions list per user
|
||||||
vi.mock('@/components/SettingsUsers/SettingsUsersTable.vue', () => ({
|
vi.mock('@/components/SettingsUsers/SettingsUsersTable.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
|
name: 'SettingsUsersTableStub',
|
||||||
|
props: {
|
||||||
|
data: { type: Object, required: false },
|
||||||
|
actions: { type: Array, required: false },
|
||||||
|
},
|
||||||
setup(_, { emit }) {
|
setup(_, { emit }) {
|
||||||
addEmitter('settingsUsersTable', emit);
|
addEmitter('settingsUsersTable', emit);
|
||||||
},
|
},
|
||||||
template: '<div />',
|
template: `
|
||||||
|
<div data-test-id="settings-users-table">
|
||||||
|
<div v-for="u in (data?.items || [])" :key="u.id" :data-test-id="'user-' + u.id">
|
||||||
|
<ul :aria-label="'Actions for user ' + u.id" role="list" :data-test-id="'actions-for-' + u.id">
|
||||||
|
<li v-for="a in (actions || []).filter(act => !act?.guard || act.guard(u))" :key="a.value" role="listitem">
|
||||||
|
<button type="button" :data-test-id="'action-' + a.value + '-' + u.id">{{ a.label || a.value }}</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -292,6 +308,19 @@ describe('SettingsUsersView', () => {
|
|||||||
expect(getByTestId('settings-users-table')).toBeInTheDocument();
|
expect(getByTestId('settings-users-table')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include delete action and pass guard for non-current user', () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(permissions, 'hasPermission')
|
||||||
|
.mockImplementation((features: string[]) => features.includes('rbac'));
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// ensure the member (id: 2) has Delete action rendered in the accessible list
|
||||||
|
const actionsList = screen.getByTestId('actions-for-2');
|
||||||
|
expect(actionsList).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('action-delete-2')).toBeInTheDocument();
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe('search functionality', () => {
|
describe('search functionality', () => {
|
||||||
it('should handle empty search', async () => {
|
it('should handle empty search', async () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
|
|||||||
guard: (user) =>
|
guard: (user) =>
|
||||||
usersStore.usersLimitNotReached && !user.firstName && settingsStore.isSmtpSetup,
|
usersStore.usersLimitNotReached && !user.firstName && settingsStore.isSmtpSetup,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: i18n.baseText('settings.users.actions.delete'),
|
||||||
|
value: 'delete',
|
||||||
|
guard: (user) =>
|
||||||
|
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
|
||||||
|
user.id !== usersStore.currentUserId,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: i18n.baseText('settings.users.actions.copyPasswordResetLink'),
|
label: i18n.baseText('settings.users.actions.copyPasswordResetLink'),
|
||||||
value: 'copyPasswordResetLink',
|
value: 'copyPasswordResetLink',
|
||||||
|
|||||||
@@ -18,4 +18,21 @@ export abstract class BasePage {
|
|||||||
protected async clickButtonByName(name: string) {
|
protected async clickButtonByName(name: string) {
|
||||||
await this.page.getByRole('button', { name }).click();
|
await this.page.getByRole('button', { name }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async waitForRestResponse(
|
||||||
|
url: string | RegExp,
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||||
|
) {
|
||||||
|
if (typeof url === 'string') {
|
||||||
|
return await this.page.waitForResponse((res) => {
|
||||||
|
const matches = res.url().includes(url);
|
||||||
|
return matches && (method ? res.request().method() === method : true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.page.waitForResponse((res) => {
|
||||||
|
const matches = url.test(res.url());
|
||||||
|
return matches && (method ? res.request().method() === method : true);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export class ProjectSettingsPage extends BasePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clickSaveButton() {
|
async clickSaveButton() {
|
||||||
await this.clickButtonByName('Save');
|
await Promise.all([
|
||||||
|
this.waitForRestResponse(/\/rest\/projects\/[^/]+$/, 'PATCH'),
|
||||||
|
this.clickButtonByName('Save'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickCancelButton() {
|
async clickCancelButton() {
|
||||||
@@ -76,4 +79,8 @@ export class ProjectSettingsPage extends BasePage {
|
|||||||
const select = this.page.getByTestId('project-members-select');
|
const select = this.page.getByTestId('project-members-select');
|
||||||
await expect(select).toBeVisible();
|
await expect(select).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async waitForProjectSettingsRestResponse() {
|
||||||
|
await this.waitForRestResponse(/\/rest\/projects\/[^/]+$/, 'GET');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Verify basic project settings form elements are visible (inner controls)
|
// Verify basic project settings form elements are visible (inner controls)
|
||||||
await expect(n8n.projectSettings.getNameInput()).toBeVisible();
|
await expect(n8n.projectSettings.getNameInput()).toBeVisible();
|
||||||
@@ -137,7 +137,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Update project name
|
// Update project name
|
||||||
const newName = 'Updated Project Name';
|
const newName = 'Updated Project Name';
|
||||||
@@ -166,7 +166,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
const table = n8n.projectSettings.getMembersTable();
|
const table = n8n.projectSettings.getMembersTable();
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Current user (owner) should not have a role dropdown
|
// Current user (owner) should not have a role dropdown
|
||||||
const currentUserRow = n8n.page.locator('tbody tr').first();
|
const currentUserRow = n8n.page.locator('tbody tr').first();
|
||||||
@@ -208,7 +208,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Verify search input is visible
|
// Verify search input is visible
|
||||||
const searchInput = n8n.page.getByTestId('project-members-search');
|
const searchInput = n8n.page.getByTestId('project-members-search');
|
||||||
@@ -232,7 +232,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Clear the project name (required field)
|
// Clear the project name (required field)
|
||||||
await n8n.projectSettings.fillProjectName('');
|
await n8n.projectSettings.fillProjectName('');
|
||||||
@@ -254,7 +254,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Initially, save and cancel buttons should be disabled (no changes)
|
// Initially, save and cancel buttons should be disabled (no changes)
|
||||||
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
|
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
|
||||||
@@ -284,7 +284,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Scroll to bottom to see delete section
|
// Scroll to bottom to see delete section
|
||||||
await n8n.page
|
await n8n.page
|
||||||
@@ -308,7 +308,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Navigate to project settings
|
// Navigate to project settings
|
||||||
await n8n.page.goto(`/projects/${projectId}/settings`);
|
await n8n.page.goto(`/projects/${projectId}/settings`);
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Update project details
|
// Update project details
|
||||||
const projectName = 'Persisted Project Name';
|
const projectName = 'Persisted Project Name';
|
||||||
@@ -325,7 +325,7 @@ test.describe('Projects', () => {
|
|||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
await n8n.page.reload();
|
await n8n.page.reload();
|
||||||
await n8n.page.waitForLoadState('domcontentloaded');
|
await n8n.projectSettings.waitForProjectSettingsRestResponse();
|
||||||
|
|
||||||
// Verify data persisted
|
// Verify data persisted
|
||||||
await n8n.projectSettings.expectProjectNameValue(projectName);
|
await n8n.projectSettings.expectProjectNameValue(projectName);
|
||||||
|
|||||||
Reference in New Issue
Block a user