mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(editor): Update users list on user settings page (#16244)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
@@ -2,7 +2,7 @@ import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants';
|
||||
import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../pages';
|
||||
import { errorToast, successToast } from '../pages/notifications';
|
||||
import { PersonalSettingsPage } from '../pages/settings-personal';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
import { getVisiblePopper } from '../utils';
|
||||
|
||||
/**
|
||||
* User A - Instance owner
|
||||
@@ -74,8 +74,8 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
||||
// List item for current user should have the `Owner` badge
|
||||
usersSettingsPage.getters
|
||||
.userItem(INSTANCE_OWNER.email)
|
||||
.find('.n8n-badge:contains("Owner")')
|
||||
.should('exist');
|
||||
.find('td:contains("Owner")')
|
||||
.should('be.visible');
|
||||
// Other users list items should contain action pop-up list
|
||||
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[0].email).should('exist');
|
||||
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[1].email).should('exist');
|
||||
@@ -90,14 +90,14 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
||||
// Change role from Member to Admin
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Member');
|
||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
|
||||
getVisibleSelect().find('li').contains('Admin').click();
|
||||
.find('button:contains("Member")')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
getVisiblePopper().find('label').contains('Admin').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Admin');
|
||||
.find('button:contains("Admin")')
|
||||
.should('be.visible');
|
||||
|
||||
usersSettingsPage.actions.loginAndVisit(
|
||||
INSTANCE_MEMBERS[0].email,
|
||||
@@ -108,15 +108,14 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
||||
// Change role from Admin to Member, then back to Admin
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Admin');
|
||||
|
||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
||||
getVisibleSelect().find('li').contains('Member').click();
|
||||
.find('button:contains("Admin")')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
getVisiblePopper().find('label').contains('Member').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Member');
|
||||
.find('button:contains("Member")')
|
||||
.should('be.visible');
|
||||
|
||||
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
|
||||
usersSettingsPage.actions.loginAndVisit(
|
||||
@@ -125,20 +124,28 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
||||
true,
|
||||
);
|
||||
|
||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
||||
getVisibleSelect().find('li').contains('Admin').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Admin');
|
||||
.find('button:contains("Member")')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
getVisiblePopper().find('label').contains('Admin').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||
.find('button:contains("Admin")')
|
||||
.should('be.visible');
|
||||
|
||||
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, true);
|
||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
|
||||
getVisibleSelect().find('li').contains('Member').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||
.find('input')
|
||||
.should('contain.value', 'Member');
|
||||
.find('button:contains("Admin")')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
getVisiblePopper().find('label').contains('Member').click();
|
||||
usersSettingsPage.getters
|
||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||
.find('button:contains("Member")')
|
||||
.should('be.visible');
|
||||
|
||||
cy.disableFeature('advancedPermissions');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MainSidebar } from './sidebar/main-sidebar';
|
||||
import { SettingsSidebar } from './sidebar/settings-sidebar';
|
||||
import { WorkflowPage } from './workflow';
|
||||
import { WorkflowsPage } from './workflows';
|
||||
import { getVisiblePopper } from '../utils';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
@@ -25,12 +26,12 @@ export class SettingsUsersPage extends BasePage {
|
||||
inviteButton: () => cy.getByTestId('settings-users-invite-button').last(),
|
||||
inviteUsersModal: () => cy.getByTestId('inviteUser-modal').last(),
|
||||
inviteUsersModalEmailsInput: () => cy.getByTestId('emails').find('input').first(),
|
||||
userListItems: () => cy.get('[data-test-id^="user-list-item"]'),
|
||||
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
|
||||
userListItems: () => cy.get('[data-test-id="settings-users-table"] tbody tr'),
|
||||
userItem: (email: string) => this.getters.userListItems().contains(email).closest('tr'),
|
||||
userActionsToggle: (email: string) =>
|
||||
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
|
||||
userRoleSelect: (email: string) =>
|
||||
this.getters.userItem(email).find('[data-test-id="user-role-select"]'),
|
||||
this.getters.userItem(email).find('[data-test-id="user-role-dropdown"]'),
|
||||
deleteUserAction: () =>
|
||||
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
||||
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
|
||||
@@ -61,8 +62,8 @@ export class SettingsUsersPage extends BasePage {
|
||||
}
|
||||
},
|
||||
opedDeleteDialog: (email: string) => {
|
||||
this.getters.userActionsToggle(email).click();
|
||||
this.getters.deleteUserAction().realClick();
|
||||
this.getters.userRoleSelect(email).find('button').should('be.visible').click();
|
||||
getVisiblePopper().find('span').contains('Remove user').click();
|
||||
this.getters.confirmDeleteModal().should('be.visible');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,6 +71,10 @@ export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto
|
||||
export { InsightsDateFilterDto } from './insights/date-filter.dto';
|
||||
|
||||
export { PaginationDto } from './pagination/pagination.dto';
|
||||
export { UsersListFilterDto } from './user/users-list-filter.dto';
|
||||
export {
|
||||
UsersListFilterDto,
|
||||
type UsersListSortOptions,
|
||||
USERS_LIST_SORT_OPTIONS,
|
||||
} from './user/users-list-filter.dto';
|
||||
|
||||
export { OidcConfigDto } from './oidc/config.dto';
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Z } from 'zod-class';
|
||||
|
||||
import { createTakeValidator, paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const USERS_LIST_SORT_OPTIONS = [
|
||||
export const USERS_LIST_SORT_OPTIONS = [
|
||||
'firstName:asc',
|
||||
'firstName:desc',
|
||||
'lastName:asc',
|
||||
'lastName:desc',
|
||||
'email:asc',
|
||||
'email:desc',
|
||||
'role:asc', // ascending order by role is Owner, Admin, Member
|
||||
'role:desc',
|
||||
'mfaEnabled:asc',
|
||||
@@ -17,6 +19,8 @@ const USERS_LIST_SORT_OPTIONS = [
|
||||
// 'lastActive:desc',
|
||||
] as const;
|
||||
|
||||
export type UsersListSortOptions = (typeof USERS_LIST_SORT_OPTIONS)[number];
|
||||
|
||||
const usersListSortByValidator = z
|
||||
.array(
|
||||
z.enum(USERS_LIST_SORT_OPTIONS, {
|
||||
|
||||
@@ -37,6 +37,7 @@ export const frontendConfig = tseslint.config(
|
||||
files: ['**/*.test.ts', '**/test/**/*.ts', '**/__tests__/**/*.ts', '**/*.stories.ts'],
|
||||
rules: {
|
||||
'import-x/no-extraneous-dependencies': 'warn',
|
||||
'vue/one-component-per-file': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,6 +14,11 @@ export type TableHeader<T> = {
|
||||
| { key: string; value: AccessorFn<T> }
|
||||
);
|
||||
export type TableSortBy = SortingState;
|
||||
export type TableOptions = {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
@@ -72,13 +77,7 @@ defineSlots<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'update:options': [
|
||||
payload: {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
sortBy: Array<{ id: string; desc: boolean }>;
|
||||
},
|
||||
];
|
||||
'update:options': [payload: TableOptions];
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'click:row': [event: MouseEvent, payload: { item: T }];
|
||||
}>();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import N8nDataTableServer from './N8nDataTableServer.vue';
|
||||
|
||||
export default N8nDataTableServer;
|
||||
export type { TableOptions, TableHeader } from './N8nDataTableServer.vue';
|
||||
|
||||
@@ -6,7 +6,7 @@ import N8nAvatar from '../N8nAvatar';
|
||||
import N8nBadge from '../N8nBadge';
|
||||
import N8nText from '../N8nText';
|
||||
|
||||
interface UsersInfoProps {
|
||||
export interface UsersInfoProps {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
@@ -56,13 +56,6 @@ const classes = computed(
|
||||
</div>
|
||||
<div>
|
||||
<N8nText data-test-id="user-email" size="small" color="text-light">{{ email }}</N8nText>
|
||||
<N8nText color="text-light"> | </N8nText>
|
||||
<N8nText
|
||||
data-test-id="user-mfa-state"
|
||||
size="small"
|
||||
:color="mfaEnabled ? 'text-light' : 'warning'"
|
||||
>{{ mfaEnabled ? '2FA Enabled' : '2FA Disabled' }}</N8nText
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"dismiss": "Dismiss",
|
||||
"unlimited": "Unlimited",
|
||||
"activate": "Activate",
|
||||
"error": "Error"
|
||||
"user": "User",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"_reusableDynamicText": {
|
||||
"readMore": "Read more",
|
||||
@@ -1930,6 +1932,7 @@
|
||||
"settings.personal.security": "Security",
|
||||
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",
|
||||
"settings.users": "Users",
|
||||
"settings.users.search.placeholder": "Search by name or email",
|
||||
"settings.users.confirmDataHandlingAfterDeletion": "What should we do with their data?",
|
||||
"settings.users.confirmUserDeletion": "Are you sure you want to delete this invited user?",
|
||||
"settings.users.delete": "Delete",
|
||||
@@ -1978,6 +1981,7 @@
|
||||
"settings.users.transferWorkflowsAndCredentials.user": "User or project to transfer to",
|
||||
"settings.users.transferWorkflowsAndCredentials.placeholder": "Select project or user",
|
||||
"settings.users.transferredToUser": "Data transferred to {projectName}",
|
||||
"settings.users.userNotFound": "User not found",
|
||||
"settings.users.userDeleted": "User deleted",
|
||||
"settings.users.userDeletedError": "Problem while deleting user",
|
||||
"settings.users.userInvited": "User invited",
|
||||
@@ -1992,6 +1996,17 @@
|
||||
"settings.users.userRoleUpdated": "Changes saved",
|
||||
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
|
||||
"settings.users.userRoleUpdatedError": "Unable to updated role",
|
||||
"settings.users.table.header.user": "@:_reusableBaseText.user",
|
||||
"settings.users.table.header.accountType": "Account Type",
|
||||
"settings.users.table.header.2fa": "2FA",
|
||||
"settings.users.table.header.lastActive": "Last Active",
|
||||
"settings.users.table.row.allProjects": "All projects",
|
||||
"settings.users.table.row.personalProject": "Personal project",
|
||||
"settings.users.table.row.deleteUser": "Remove user",
|
||||
"settings.users.table.row.role.description.admin": "Full access to all workflows, credentials, projects, users and more",
|
||||
"settings.users.table.row.role.description.member": "Manage and create own workflows and credentials",
|
||||
"settings.users.table.row.2fa.enabled": "@:_reusableBaseText.enabled",
|
||||
"settings.users.table.row.2fa.disabled": "@:_reusableBaseText.disabled",
|
||||
"settings.api": "API",
|
||||
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
|
||||
"settings.api.scopes.upgrade.link": "Upgrade",
|
||||
|
||||
@@ -138,3 +138,20 @@ export const mockedStore = <TStoreDef extends () => unknown>(
|
||||
};
|
||||
|
||||
export type MockedStore<T extends () => unknown> = ReturnType<typeof mockedStore<T>>;
|
||||
|
||||
export type Emitter = (event: string, ...args: unknown[]) => void;
|
||||
export type Emitters<T extends string> = Record<
|
||||
T,
|
||||
{
|
||||
emit: Emitter;
|
||||
}
|
||||
>;
|
||||
export const useEmitters = <T extends string>() => {
|
||||
const emitters = {} as Emitters<T>;
|
||||
return {
|
||||
emitters,
|
||||
addEmitter: (name: T, emitter: Emitter) => {
|
||||
emitters[name] = { emit: emitter };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
LoginRequestDto,
|
||||
PasswordUpdateRequestDto,
|
||||
Role,
|
||||
SettingsUpdateRequestDto,
|
||||
UsersList,
|
||||
UsersListFilterDto,
|
||||
@@ -10,7 +11,6 @@ import type {
|
||||
CurrentUserResponse,
|
||||
IPersonalizationLatestVersion,
|
||||
IUserResponse,
|
||||
InvitableRoleName,
|
||||
} from '@/Interface';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
import type { IDataObject, IUserSettings } from 'n8n-workflow';
|
||||
@@ -158,7 +158,7 @@ export async function submitPersonalizationSurvey(
|
||||
|
||||
export interface UpdateGlobalRolePayload {
|
||||
id: string;
|
||||
newRoleName: InvitableRoleName;
|
||||
newRoleName: Role;
|
||||
}
|
||||
|
||||
export async function updateGlobalRole(
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { type MockedStore, mockedStore } from '@/__tests__/utils';
|
||||
import DeleteUserModal from './DeleteUserModal.vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { getDropdownItems } from '@/__tests__/utils';
|
||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||
import { createUser } from '@/__tests__/data/users';
|
||||
|
||||
import { DELETE_USER_MODAL_KEY } from '@/constants';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { ROLE, type UsersList, type User } from '@n8n/api-types';
|
||||
|
||||
const ModalStub = {
|
||||
template: `
|
||||
@@ -22,9 +22,42 @@ const ModalStub = {
|
||||
`,
|
||||
};
|
||||
|
||||
const loggedInUser = createUser();
|
||||
const invitedUser = createUser({ firstName: undefined });
|
||||
const user = createUser();
|
||||
const loggedInUser: User = {
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
role: ROLE.Admin,
|
||||
isOwner: true,
|
||||
isPending: false,
|
||||
settings: {},
|
||||
};
|
||||
const invitedUser: User = {
|
||||
id: '3',
|
||||
email: 'pending@example.com',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: true,
|
||||
settings: {},
|
||||
inviteAcceptUrl: 'https://example.com/invite/123',
|
||||
};
|
||||
const user: User = {
|
||||
id: '2',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
settings: {},
|
||||
};
|
||||
|
||||
const mockUsersList: UsersList = {
|
||||
items: [loggedInUser, user, invitedUser],
|
||||
count: 3,
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
[STORES.UI]: {
|
||||
@@ -43,13 +76,6 @@ const initialState = {
|
||||
ProjectTypes.Team,
|
||||
].map(createProjectListItem),
|
||||
},
|
||||
[STORES.USERS]: {
|
||||
usersById: {
|
||||
[loggedInUser.id]: loggedInUser,
|
||||
[user.id]: user,
|
||||
[invitedUser.id]: invitedUser,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const global = {
|
||||
@@ -60,32 +86,47 @@ const global = {
|
||||
|
||||
const renderModal = createComponentRenderer(DeleteUserModal);
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
let usersStore: MockedStore<typeof useUsersStore>;
|
||||
|
||||
describe('DeleteUserModal', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createTestingPinia({ initialState });
|
||||
usersStore = mockedStore(useUsersStore);
|
||||
|
||||
usersStore.usersList = {
|
||||
state: mockUsersList,
|
||||
isLoading: false,
|
||||
execute: vi.fn(),
|
||||
isReady: true,
|
||||
error: null,
|
||||
then: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should delete invited users', async () => {
|
||||
const { getByTestId } = renderModal({
|
||||
props: {
|
||||
activeId: invitedUser.id,
|
||||
modalName: DELETE_USER_MODAL_KEY,
|
||||
data: {
|
||||
userId: invitedUser.id,
|
||||
},
|
||||
},
|
||||
global,
|
||||
pinia,
|
||||
});
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
await userEvent.click(getByTestId('confirm-delete-user-button'));
|
||||
|
||||
expect(userStore.deleteUser).toHaveBeenCalledWith({ id: invitedUser.id });
|
||||
expect(usersStore.deleteUser).toHaveBeenCalledWith({ id: invitedUser.id });
|
||||
});
|
||||
|
||||
it('should delete user and transfer workflows and credentials', async () => {
|
||||
const { getByTestId, getAllByRole } = renderModal({
|
||||
props: {
|
||||
activeId: user.id,
|
||||
modalName: DELETE_USER_MODAL_KEY,
|
||||
data: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
global,
|
||||
pinia,
|
||||
@@ -102,12 +143,10 @@ describe('DeleteUserModal', () => {
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
await userEvent.click(projectSelectDropdownItems[0]);
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
expect(confirmButton).toBeEnabled();
|
||||
await userEvent.click(confirmButton);
|
||||
|
||||
expect(userStore.deleteUser).toHaveBeenCalledWith({
|
||||
expect(usersStore.deleteUser).toHaveBeenCalledWith({
|
||||
id: user.id,
|
||||
transferId: expect.any(String),
|
||||
});
|
||||
@@ -116,14 +155,15 @@ describe('DeleteUserModal', () => {
|
||||
it('should delete user without transfer', async () => {
|
||||
const { getByTestId, getAllByRole, getByRole } = renderModal({
|
||||
props: {
|
||||
activeId: user.id,
|
||||
modalName: DELETE_USER_MODAL_KEY,
|
||||
data: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
global,
|
||||
pinia,
|
||||
});
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
const confirmButton = getByTestId('confirm-delete-user-button');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
|
||||
@@ -138,7 +178,7 @@ describe('DeleteUserModal', () => {
|
||||
expect(confirmButton).toBeEnabled();
|
||||
|
||||
await userEvent.click(confirmButton);
|
||||
expect(userStore.deleteUser).toHaveBeenCalledWith({
|
||||
expect(usersStore.deleteUser).toHaveBeenCalledWith({
|
||||
id: user.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,10 @@ import { useI18n } from '@n8n/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
activeId: string;
|
||||
data: {
|
||||
userId: string;
|
||||
afterDelete?: () => Promise<void>;
|
||||
};
|
||||
}>();
|
||||
|
||||
const modalBus = createEventBus();
|
||||
@@ -25,17 +28,18 @@ const usersStore = useUsersStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const userToDelete = computed(() => {
|
||||
if (!props.activeId) return null;
|
||||
if (!props.data?.userId) return null;
|
||||
|
||||
return usersStore.usersById[props.activeId];
|
||||
return usersStore.usersList.state.items.find((user) => user.id === props.data.userId);
|
||||
});
|
||||
|
||||
const isPending = computed(() => {
|
||||
return userToDelete.value ? !userToDelete.value.firstName : false;
|
||||
});
|
||||
const isPending = computed(() => !userToDelete.value?.firstName);
|
||||
|
||||
const title = computed(() => {
|
||||
const user = userToDelete.value?.fullName ?? userToDelete.value?.email ?? '';
|
||||
const user =
|
||||
userToDelete.value?.firstName && userToDelete.value.lastName
|
||||
? `${userToDelete.value.firstName} ${userToDelete.value.lastName}`
|
||||
: (userToDelete.value?.email ?? '');
|
||||
|
||||
return i18n.baseText('settings.users.deleteUser', { interpolate: { user } });
|
||||
});
|
||||
@@ -52,11 +56,7 @@ const enabled = computed(() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (operation.value === 'transfer' && selectedProject.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return !!(operation.value === 'transfer' && selectedProject.value);
|
||||
});
|
||||
|
||||
const projects = computed(() => {
|
||||
@@ -81,7 +81,7 @@ async function onSubmit() {
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
const params = { id: props.activeId } as { id: string; transferId?: string };
|
||||
const params = { id: props.data.userId } as { id: string; transferId?: string };
|
||||
if (operation.value === 'transfer' && selectedProject.value) {
|
||||
params.transferId = selectedProject.value.id;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ async function onSubmit() {
|
||||
message,
|
||||
});
|
||||
|
||||
await props.data.afterDelete?.();
|
||||
modalBus.emit('close');
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.users.userDeletedError'));
|
||||
|
||||
@@ -19,6 +19,13 @@ import { useClipboard } from '@/composables/useClipboard';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
afterInvite?: () => Promise<void>;
|
||||
};
|
||||
}>();
|
||||
|
||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
@@ -226,6 +233,8 @@ async function onSubmit() {
|
||||
} else {
|
||||
modalBus.emit('close');
|
||||
}
|
||||
|
||||
await props.data.afterInvite?.();
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('settings.users.usersInvitedError'));
|
||||
}
|
||||
|
||||
@@ -163,12 +163,14 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
||||
<InviteUsersModal />
|
||||
<template #default="{ modalName, data }">
|
||||
<InviteUsersModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="DELETE_USER_MODAL_KEY">
|
||||
<template #default="{ modalName, activeId }">
|
||||
<DeleteUserModal :modal-name="modalName" :active-id="activeId" />
|
||||
<template #default="{ modalName, data }">
|
||||
<DeleteUserModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { ROLE, type UsersList } from '@n8n/api-types';
|
||||
import { type UserAction } from '@n8n/design-system';
|
||||
import SettingsUsersActionsCell from '@/components/SettingsUsers/SettingsUsersActionsCell.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { IUser } from '@/Interface';
|
||||
|
||||
const baseUser: UsersList['items'][number] = {
|
||||
id: '1',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
mfaEnabled: false,
|
||||
settings: {},
|
||||
};
|
||||
|
||||
const mockActions: Array<UserAction<IUser>> = [
|
||||
{ value: 'copyInviteLink', label: 'Copy invite link' },
|
||||
{ value: 'copyPasswordResetLink', label: 'Copy password reset link' },
|
||||
];
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
describe('SettingsUsersActionsCell', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent = createComponentRenderer(SettingsUsersActionsCell, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
const props = { data: baseUser, actions: [] };
|
||||
const { container } = renderComponent({ props });
|
||||
expect(container.firstChild).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('should render the action toggle with provided actions', () => {
|
||||
const props = { data: baseUser, actions: mockActions };
|
||||
renderComponent({ props });
|
||||
|
||||
expect(screen.getByTestId('action-copyInviteLink')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('action-copyPasswordResetLink')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should emit "action" with correct payload when an action is clicked', async () => {
|
||||
const props = { data: baseUser, actions: mockActions };
|
||||
const { emitted } = renderComponent({ props });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId('action-copyInviteLink'));
|
||||
|
||||
expect(emitted()).toHaveProperty('action');
|
||||
expect(emitted().action[0]).toEqual([{ action: 'copyInviteLink', userId: '1' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup="">
|
||||
import type { UsersList } from '@n8n/api-types';
|
||||
import type { UserAction } from '@n8n/design-system';
|
||||
import type { IUser } from '@/Interface';
|
||||
|
||||
const props = defineProps<{
|
||||
data: UsersList['items'][number];
|
||||
actions: Array<UserAction<IUser>>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [value: { action: string; userId: string }];
|
||||
}>();
|
||||
|
||||
const onUserAction = (action: string) => {
|
||||
emit('action', {
|
||||
action,
|
||||
userId: props.data.id,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<N8nActionToggle
|
||||
v-if="!props.data.isOwner && props.data.signInType !== 'ldap' && props.actions.length > 0"
|
||||
placement="bottom"
|
||||
:actions="props.actions"
|
||||
theme="dark"
|
||||
@action="onUserAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, within } from '@testing-library/vue';
|
||||
import { vi } from 'vitest';
|
||||
import { ROLE, type UsersList } from '@n8n/api-types';
|
||||
import SettingsUsersProjectsCell from '@/components/SettingsUsers/SettingsUsersProjectsCell.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
// Mock N8nTooltip
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
N8nTooltip: {
|
||||
name: 'N8nTooltip',
|
||||
template: `
|
||||
<div>
|
||||
<slot name="content" />
|
||||
<slot />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const baseUser: UsersList['items'][number] = {
|
||||
id: '1',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
mfaEnabled: false,
|
||||
settings: {},
|
||||
projectRelations: [],
|
||||
};
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
describe('SettingsUsersProjectsCell', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent = createComponentRenderer(SettingsUsersProjectsCell, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display "All projects" for an Owner', () => {
|
||||
renderComponent({ props: { data: { ...baseUser, role: ROLE.Owner } } });
|
||||
expect(screen.getByText('All projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "All projects" for an Admin', () => {
|
||||
renderComponent({ props: { data: { ...baseUser, role: ROLE.Admin } } });
|
||||
expect(screen.getByText('All projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Personal project" if user has no project relations', () => {
|
||||
renderComponent({ props: { data: { ...baseUser, projectRelations: [] } } });
|
||||
expect(screen.getByText('Personal project')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a list of project names', () => {
|
||||
const props = {
|
||||
data: {
|
||||
...baseUser,
|
||||
projectRelations: [{ name: 'Project A' }, { name: 'Project B' }],
|
||||
},
|
||||
};
|
||||
renderComponent({ props });
|
||||
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a tooltip with additional projects when list is long', () => {
|
||||
const props = {
|
||||
data: {
|
||||
...baseUser,
|
||||
projectRelations: [
|
||||
{ name: 'Project A' },
|
||||
{ name: 'Project B' },
|
||||
{ name: 'Project C' },
|
||||
{ name: 'Project D' },
|
||||
],
|
||||
},
|
||||
};
|
||||
renderComponent({ props });
|
||||
|
||||
// Visible projects
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project B')).toBeInTheDocument();
|
||||
|
||||
// Additional count
|
||||
expect(screen.getByText('+ 2')).toBeInTheDocument();
|
||||
|
||||
// Projects inside the tooltip content
|
||||
const list = screen.getByRole('list');
|
||||
expect(within(list).getByText('Project C')).toBeInTheDocument();
|
||||
expect(within(list).getByText('Project D')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup="">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ROLE, type Role, type UsersList } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nTooltip } from '@n8n/design-system';
|
||||
|
||||
const props = defineProps<{ data: UsersList['items'][number] }>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const rolesAccessingAllProjects = ref<Role[]>([ROLE.Owner, ROLE.Admin]);
|
||||
|
||||
const visibleProjectsNum = ref(2);
|
||||
const allProjects = computed(() => {
|
||||
if (props.data.role && rolesAccessingAllProjects.value.includes(props.data.role)) {
|
||||
return [i18n.baseText('settings.users.table.row.allProjects')];
|
||||
} else if (!props.data.projectRelations?.length) {
|
||||
return [i18n.baseText('settings.users.table.row.personalProject')];
|
||||
} else {
|
||||
return props.data.projectRelations.map(({ name }) => name);
|
||||
}
|
||||
});
|
||||
const projects = computed(() => ({
|
||||
visible: allProjects.value.slice(0, visibleProjectsNum.value),
|
||||
additional: allProjects.value.slice(visibleProjectsNum.value),
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.projects">
|
||||
<template v-for="(project, index) in projects.visible" :key="index">
|
||||
<span :class="$style.project">{{ project }}</span>
|
||||
<span v-if="index < projects.visible.length - 1" :class="$style.comma">,</span>
|
||||
</template>
|
||||
<span v-if="projects.additional.length > 0" :class="$style.comma">,</span>
|
||||
<N8nTooltip v-if="projects.additional.length > 0">
|
||||
<template #content>
|
||||
<ul :class="$style.projectList">
|
||||
<li v-for="(project, index) in projects.additional" :key="index">{{ project }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<span :class="$style.project">+ {{ projects.additional.length }}</span>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projects {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
max-width: max-content;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comma {
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.projectList {
|
||||
padding: 0 var(--spacing-s);
|
||||
|
||||
li {
|
||||
list-style: disc outside;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import { ROLE, type UsersList } from '@n8n/api-types';
|
||||
import { type ActionDropdownItem } from '@n8n/design-system';
|
||||
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
// Mock N8nActionDropdown to simplify testing
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
N8nActionDropdown: {
|
||||
name: 'N8nActionDropdown',
|
||||
props: {
|
||||
items: { type: Array, required: true },
|
||||
},
|
||||
emits: ['select'],
|
||||
template: `
|
||||
<div>
|
||||
<div data-test-id="activator">
|
||||
<slot name="activator" />
|
||||
</div>
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<button :data-test-id="'action-' + item.id" @click="$emit('select', item.id)">
|
||||
<slot name="menuItem" v-bind="item" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockUser: UsersList['items'][number] = {
|
||||
id: '1',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
mfaEnabled: false,
|
||||
settings: {},
|
||||
};
|
||||
|
||||
const mockRoles = {
|
||||
[ROLE.Owner]: { label: 'Owner', desc: '' },
|
||||
[ROLE.Admin]: { label: 'Admin', desc: 'Admin Description' },
|
||||
[ROLE.Member]: { label: 'Member', desc: 'Member Description' },
|
||||
[ROLE.Default]: { label: 'Default', desc: '' },
|
||||
};
|
||||
|
||||
const mockActions: ActionDropdownItem[] = [
|
||||
{ id: ROLE.Member, label: 'Member' },
|
||||
{ id: ROLE.Admin, label: 'Admin' },
|
||||
{ id: 'delete', label: 'Delete User', divided: true },
|
||||
];
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
describe('SettingsUsersRoleCell', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent = createComponentRenderer(SettingsUsersRoleCell, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: mockUser,
|
||||
roles: mockRoles,
|
||||
actions: mockActions,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display the current role of the user', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('button', { name: 'Member' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not be editable for an owner', () => {
|
||||
renderComponent({
|
||||
props: {
|
||||
data: { ...mockUser, role: ROLE.Owner, isOwner: true },
|
||||
},
|
||||
});
|
||||
|
||||
// The dropdown activator should not be present
|
||||
expect(screen.queryByTestId('activator')).not.toBeInTheDocument();
|
||||
// The role label should be a simple span
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should emit "update:role" when a new role is selected', async () => {
|
||||
const { emitted } = renderComponent();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByTestId('action-global:admin'));
|
||||
|
||||
expect(emitted()).toHaveProperty('update:role');
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ROLE, type Role, type UsersList } from '@n8n/api-types';
|
||||
import { type ActionDropdownItem, N8nActionDropdown, N8nIcon } from '@n8n/design-system';
|
||||
|
||||
const props = defineProps<{
|
||||
data: UsersList['items'][number];
|
||||
roles: Record<Role, { label: string; desc: string }>;
|
||||
actions: ActionDropdownItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:role': [payload: { role: Role; userId: string }];
|
||||
}>();
|
||||
|
||||
const selectedRole = ref<Role>(props.data.role ?? ROLE.Default);
|
||||
const isEditable = computed(() => props.data.role !== ROLE.Owner);
|
||||
const roleLabel = computed(() => props.roles[selectedRole.value].label);
|
||||
|
||||
const onActionSelect = (role: string) => {
|
||||
emit('update:role', {
|
||||
role: role as Role,
|
||||
userId: props.data.id,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<N8nActionDropdown
|
||||
v-if="isEditable"
|
||||
placement="bottom-start"
|
||||
:items="props.actions"
|
||||
data-test-id="user-role-dropdown"
|
||||
@select="onActionSelect"
|
||||
>
|
||||
<template #activator>
|
||||
<button :class="$style.roleLabel" type="button">
|
||||
<N8nText color="text-dark">{{ roleLabel }}</N8nText>
|
||||
<N8nIcon color="text-dark" icon="chevron-down" size="large" />
|
||||
</button>
|
||||
</template>
|
||||
<template #menuItem="item">
|
||||
<N8nText v-if="item.id === 'delete'" color="text-dark" :class="$style.removeUser">{{
|
||||
item.label
|
||||
}}</N8nText>
|
||||
<ElRadio
|
||||
v-else
|
||||
:model-value="selectedRole"
|
||||
:label="item.id"
|
||||
@update:model-value="selectedRole = item.id as Role"
|
||||
>
|
||||
<span :class="$style.radioLabel">
|
||||
<N8nText color="text-dark" class="pb-3xs">{{ item.label }}</N8nText>
|
||||
<N8nText color="text-dark" size="small">{{
|
||||
props.roles[item.id as Role].desc
|
||||
}}</N8nText>
|
||||
</span>
|
||||
</ElRadio>
|
||||
</template>
|
||||
</N8nActionDropdown>
|
||||
<span v-else>{{ roleLabel }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.roleLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
max-width: 268px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-2xs) 0;
|
||||
|
||||
span {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.removeUser {
|
||||
display: block;
|
||||
padding: var(--spacing-2xs) var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, within } from '@testing-library/vue';
|
||||
import { vi } from 'vitest';
|
||||
import { ROLE, type UsersList } from '@n8n/api-types';
|
||||
import { type UserAction } from '@n8n/design-system';
|
||||
import SettingsUsersTable from '@/components/SettingsUsers/SettingsUsersTable.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useEmitters } from '@/__tests__/utils';
|
||||
import type { IUser } from '@/Interface';
|
||||
import type { PermissionType, PermissionTypeOptions } from '@/types/rbac';
|
||||
|
||||
const { emitters, addEmitter } = useEmitters<
|
||||
'settingsUsersRoleCell' | 'settingsUsersActionsCell' | 'n8nDataTableServer'
|
||||
>();
|
||||
|
||||
// Mock child components and composables
|
||||
const hasPermission = vi.fn(
|
||||
(permissionNames: PermissionType[], options?: Partial<PermissionTypeOptions>) =>
|
||||
!!(permissionNames || options || []),
|
||||
);
|
||||
vi.mock('@/utils/rbac/permissions', () => ({
|
||||
hasPermission: (
|
||||
permissionNames: PermissionType[],
|
||||
options?: Partial<PermissionTypeOptions>,
|
||||
): boolean => hasPermission(permissionNames, options),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/SettingsUsers/SettingsUsersRoleCell.vue', () => ({
|
||||
default: defineComponent({
|
||||
setup(_, { emit }) {
|
||||
addEmitter('settingsUsersRoleCell', emit);
|
||||
},
|
||||
template: '<div data-test-id="user-role" />',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/SettingsUsers/SettingsUsersActionsCell.vue', () => ({
|
||||
default: defineComponent({
|
||||
props: {
|
||||
data: { type: Object, required: true },
|
||||
actions: { type: Array, required: true },
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
addEmitter('settingsUsersActionsCell', emit);
|
||||
},
|
||||
template:
|
||||
'<div :data-test-id="\'actions-cell-\' + data.id" :data-actions-count="actions.length" />',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock N8nDataTableServer to emit events
|
||||
vi.mock('@n8n/design-system', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
N8nDataTableServer: defineComponent({
|
||||
props: {
|
||||
headers: { type: Array, required: true },
|
||||
items: { type: Array, required: true },
|
||||
itemsLength: { type: Number, required: true },
|
||||
},
|
||||
setup(_, { emit }) {
|
||||
addEmitter('n8nDataTableServer', emit);
|
||||
},
|
||||
template: `
|
||||
<ul>
|
||||
<li v-for="item in items" :key="item.id" :data-test-id="'user-row-' + item.id">
|
||||
<div v-for="header in headers" :key="header.key">
|
||||
<slot :name="'item.' + header.key" :item="item"
|
||||
:value="header.value ? header.value(item) : item[header.key]">
|
||||
<!-- Fallback content -->
|
||||
<span v-if="header.value">{{ header.value(item) }}</span>
|
||||
<span v-else>{{ item[header.key] }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
</li>
|
||||
</ul>`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUsersList: UsersList = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
email: 'owner@example.com',
|
||||
firstName: 'Owner',
|
||||
lastName: 'User',
|
||||
role: ROLE.Owner,
|
||||
isOwner: true,
|
||||
isPending: false,
|
||||
mfaEnabled: true,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
mfaEnabled: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'pending@example.com',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: true,
|
||||
mfaEnabled: false,
|
||||
settings: {},
|
||||
inviteAcceptUrl: 'https://example.com/invite/123',
|
||||
},
|
||||
],
|
||||
count: 3,
|
||||
};
|
||||
|
||||
const mockActions: Array<UserAction<IUser>> = [
|
||||
{
|
||||
value: 'delete',
|
||||
label: 'Delete',
|
||||
},
|
||||
{
|
||||
value: 'reinvite',
|
||||
label: 'Reinvite',
|
||||
guard: (user) => user.isPendingUser,
|
||||
},
|
||||
];
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
|
||||
describe('SettingsUsersTable', () => {
|
||||
beforeEach(() => {
|
||||
renderComponent = createComponentRenderer(SettingsUsersTable, {
|
||||
pinia: createTestingPinia(),
|
||||
props: {
|
||||
data: mockUsersList,
|
||||
actions: mockActions,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
hasPermission.mockReturnValue(true); // Default to having permission
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render user data correctly in the table', () => {
|
||||
renderComponent();
|
||||
|
||||
// Check for owner user
|
||||
const ownerRow = screen.getByTestId('user-row-1');
|
||||
expect(within(ownerRow).getByText(/Owner User/)).toBeInTheDocument();
|
||||
expect(within(ownerRow).getByText('owner@example.com')).toBeInTheDocument();
|
||||
expect(within(ownerRow).getByText('Enabled')).toBeInTheDocument(); // 2FA
|
||||
|
||||
// Check for member user
|
||||
const memberRow = screen.getByTestId('user-row-2');
|
||||
expect(within(memberRow).getByText(/Member User/)).toBeInTheDocument();
|
||||
expect(within(memberRow).getByText('member@example.com')).toBeInTheDocument();
|
||||
expect(within(memberRow).getByText('Disabled')).toBeInTheDocument(); // 2FA
|
||||
});
|
||||
|
||||
it('should delegate update:options event from N8nDataTableServer', () => {
|
||||
const { emitted } = renderComponent();
|
||||
emitters.n8nDataTableServer.emit('update:options', { page: 1, itemsPerPage: 20 });
|
||||
|
||||
expect(emitted()).toHaveProperty('update:options');
|
||||
expect(emitted()['update:options'][0]).toEqual([{ page: 1, itemsPerPage: 20 }]);
|
||||
});
|
||||
|
||||
describe('role changing', () => {
|
||||
it('should render role update component when user has permission', () => {
|
||||
hasPermission.mockReturnValue(true);
|
||||
renderComponent();
|
||||
screen.getAllByTestId('user-role').forEach((roleCell) => {
|
||||
expect(roleCell).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit "update:role" when a new role is selected', () => {
|
||||
const { emitted } = renderComponent();
|
||||
emitters.settingsUsersRoleCell.emit('update:role', { role: 'global:admin', userId: '2' });
|
||||
|
||||
expect(emitted()).toHaveProperty('update:role');
|
||||
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', () => {
|
||||
hasPermission.mockReturnValue(false);
|
||||
renderComponent();
|
||||
|
||||
const memberRow = screen.getByTestId('user-row-2');
|
||||
expect(within(memberRow).queryByTestId('user-role')).not.toBeInTheDocument();
|
||||
expect(within(memberRow).getByText('Member')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('user actions', () => {
|
||||
it('should filter actions for the owner user (should be none)', () => {
|
||||
renderComponent();
|
||||
const ownerActions = screen.getByTestId('actions-cell-1');
|
||||
expect(ownerActions.dataset.actionsCount).toBe('0');
|
||||
});
|
||||
|
||||
it('should filter actions based on guard function', () => {
|
||||
renderComponent();
|
||||
|
||||
// Member user is not pending, so 'reinvite' action should be filtered out
|
||||
const memberActions = screen.getByTestId('actions-cell-2');
|
||||
expect(memberActions.dataset.actionsCount).toBe('1'); // Only 'delete'
|
||||
|
||||
// Pending user should have 'reinvite' action
|
||||
const pendingActions = screen.getByTestId('actions-cell-3');
|
||||
expect(pendingActions.dataset.actionsCount).toBe('2'); // 'delete' and 'reinvite'
|
||||
});
|
||||
|
||||
it('should delegate action events from SettingsUsersActionsCell', async () => {
|
||||
const { emitted } = renderComponent();
|
||||
emitters.settingsUsersActionsCell.emit('action', { action: 'delete', userId: '2' });
|
||||
|
||||
expect(emitted()).toHaveProperty('action');
|
||||
expect(emitted().action[0]).toEqual([{ action: 'delete', userId: '2' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { ROLE, type Role, type UsersList } from '@n8n/api-types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import {
|
||||
N8nUserInfo,
|
||||
N8nDataTableServer,
|
||||
type UserAction,
|
||||
type ActionDropdownItem,
|
||||
} from '@n8n/design-system';
|
||||
import type { TableHeader, TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import type { IUser } from '@/Interface';
|
||||
import SettingsUsersRoleCell from '@/components/SettingsUsers/SettingsUsersRoleCell.vue';
|
||||
import SettingsUsersProjectsCell from '@/components/SettingsUsers/SettingsUsersProjectsCell.vue';
|
||||
import SettingsUsersActionsCell from '@/components/SettingsUsers/SettingsUsersActionsCell.vue';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import type { UsersInfoProps } from '@n8n/design-system/components/N8nUserInfo/UserInfo.vue';
|
||||
|
||||
type Item = UsersList['items'][number];
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
data: UsersList;
|
||||
actions: Array<UserAction<IUser>>;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:options': [payload: TableOptions];
|
||||
'update:role': [payload: { role: Role; userId: string }];
|
||||
action: [value: { action: string; userId: string }];
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.data.items);
|
||||
const headers = ref<Array<TableHeader<Item>>>([
|
||||
{
|
||||
title: i18n.baseText('settings.users.table.header.user'),
|
||||
key: 'name',
|
||||
width: 400,
|
||||
value(row) {
|
||||
return {
|
||||
...row,
|
||||
// TODO: Fix UsersInfoProps type, it should be aligned with the API response and implement 'isPending' instead of `isPendingUser`
|
||||
isPendingUser: row.isPending,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.users.table.header.accountType'),
|
||||
key: 'role',
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.users.table.header.2fa'),
|
||||
key: 'mfaEnabled',
|
||||
value(row) {
|
||||
return row.mfaEnabled
|
||||
? i18n.baseText('settings.users.table.row.2fa.enabled')
|
||||
: i18n.baseText('settings.users.table.row.2fa.disabled');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('projects.menu.title'),
|
||||
key: 'projects',
|
||||
disableSort: true,
|
||||
// TODO: Fix TableHeader type so it allows `disableSort` without `value` (which is not used here)
|
||||
value() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
align: 'end',
|
||||
width: 46,
|
||||
disableSort: true,
|
||||
// TODO: Fix TableHeader type so it allows `disableSort` without `value` (which is not used here)
|
||||
value() {
|
||||
return;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const roles = computed<Record<Role, { label: string; desc: string }>>(() => ({
|
||||
[ROLE.Owner]: { label: i18n.baseText('auth.roles.owner'), desc: '' },
|
||||
[ROLE.Admin]: {
|
||||
label: i18n.baseText('auth.roles.admin'),
|
||||
desc: i18n.baseText('settings.users.table.row.role.description.admin'),
|
||||
},
|
||||
[ROLE.Member]: {
|
||||
label: i18n.baseText('auth.roles.member'),
|
||||
desc: i18n.baseText('settings.users.table.row.role.description.member'),
|
||||
},
|
||||
[ROLE.Default]: { label: i18n.baseText('auth.roles.default'), desc: '' },
|
||||
}));
|
||||
const roleActions = computed<ActionDropdownItem[]>(() => [
|
||||
{
|
||||
id: ROLE.Member,
|
||||
label: i18n.baseText('auth.roles.member'),
|
||||
},
|
||||
{
|
||||
id: ROLE.Admin,
|
||||
label: i18n.baseText('auth.roles.admin'),
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: i18n.baseText('settings.users.table.row.deleteUser'),
|
||||
divided: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const canUpdateRole = computed((): boolean => {
|
||||
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
|
||||
});
|
||||
|
||||
const filterActions = (user: UsersList['items'][number]) => {
|
||||
if (user.isOwner) return [];
|
||||
|
||||
return props.actions.filter(
|
||||
(action) => action.guard?.({ ...user, isPendingUser: user.isPending } as IUser) ?? true,
|
||||
);
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<N8nDataTableServer
|
||||
:headers="headers"
|
||||
:items="rows"
|
||||
:items-length="data.count"
|
||||
@update:options="emit('update:options', $event)"
|
||||
>
|
||||
<template #[`item.name`]="{ value }">
|
||||
<div class="pt-xs pb-xs">
|
||||
<N8nUserInfo v-bind="value as UsersInfoProps" />
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.role`]="{ item }">
|
||||
<SettingsUsersRoleCell
|
||||
v-if="canUpdateRole"
|
||||
:data="item"
|
||||
:roles="roles"
|
||||
:actions="roleActions"
|
||||
@update:role="onRoleChange"
|
||||
/>
|
||||
<N8nText v-else color="text-dark">{{ roles[item.role ?? ROLE.Default].label }}</N8nText>
|
||||
</template>
|
||||
<template #[`item.projects`]="{ item }">
|
||||
<SettingsUsersProjectsCell :data="item" />
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<SettingsUsersActionsCell
|
||||
:data="item"
|
||||
:actions="filterActions(item)"
|
||||
@action="$emit('action', $event)"
|
||||
/>
|
||||
</template>
|
||||
</N8nDataTableServer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
import {
|
||||
type LoginRequestDto,
|
||||
type PasswordUpdateRequestDto,
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
type UserUpdateRequestDto,
|
||||
type User,
|
||||
ROLE,
|
||||
type UsersListFilterDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { UpdateGlobalRolePayload } from '@/api/users';
|
||||
import * as usersApi from '@/api/users';
|
||||
@@ -302,13 +304,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
};
|
||||
|
||||
const updateOtherUserSettings = async (userId: string, settings: SettingsUpdateRequestDto) => {
|
||||
const updatedSettings = await usersApi.updateOtherUserSettings(
|
||||
rootStore.restApiContext,
|
||||
userId,
|
||||
settings,
|
||||
);
|
||||
usersById.value[userId].settings = updatedSettings;
|
||||
addUsers([usersById.value[userId]]);
|
||||
await usersApi.updateOtherUserSettings(rootStore.restApiContext, userId, settings);
|
||||
};
|
||||
|
||||
const updateCurrentUserPassword = async (params: PasswordUpdateRequestDto) => {
|
||||
@@ -426,6 +422,16 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
}
|
||||
};
|
||||
|
||||
const usersList = useAsyncState(
|
||||
async (filter?: UsersListFilterDto) =>
|
||||
await usersApi.getUsers(rootStore.restApiContext, filter),
|
||||
{
|
||||
count: 0,
|
||||
items: [],
|
||||
},
|
||||
{ immediate: false, resetOnExecute: false },
|
||||
);
|
||||
|
||||
return {
|
||||
initialized,
|
||||
currentUserId,
|
||||
@@ -480,5 +486,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
||||
setCalloutDismissed,
|
||||
submitContactEmail,
|
||||
submitContactInfo,
|
||||
usersList,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,347 +1,483 @@
|
||||
import { within } from '@testing-library/vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
||||
import { vi } from 'vitest';
|
||||
import { type FrontendSettings, ROLE, type UsersList } from '@n8n/api-types';
|
||||
import {
|
||||
INVITE_USER_MODAL_KEY,
|
||||
DELETE_USER_MODAL_KEY,
|
||||
EnterpriseEditionFeature,
|
||||
} from '@/constants';
|
||||
import SettingsUsersView from '@/views/SettingsUsersView.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useEmitters } from '@/__tests__/utils';
|
||||
import {
|
||||
cleanupAppModals,
|
||||
createAppModals,
|
||||
mockedStore,
|
||||
type MockedStore,
|
||||
} from '@/__tests__/utils';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { createUser } from '@/__tests__/data/users';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { createTestingPinia, type TestingOptions } from '@pinia/testing';
|
||||
import merge from 'lodash/merge';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
const loggedInUser = createUser();
|
||||
const invitedUser = createUser({
|
||||
firstName: undefined,
|
||||
inviteAcceptUrl: 'dummy',
|
||||
role: 'global:admin',
|
||||
});
|
||||
const user = createUser();
|
||||
const userWithDisabledSSO = createUser({
|
||||
settings: { allowSSOManualLogin: true },
|
||||
});
|
||||
const { emitters, addEmitter } = useEmitters<'settingsUsersTable'>();
|
||||
|
||||
const initialState = {
|
||||
[STORES.USERS]: {
|
||||
currentUserId: loggedInUser.id,
|
||||
usersById: {
|
||||
[loggedInUser.id]: loggedInUser,
|
||||
[invitedUser.id]: invitedUser,
|
||||
[user.id]: user,
|
||||
[userWithDisabledSSO.id]: userWithDisabledSSO,
|
||||
// Mock the SettingsUsersTable component to emit events when clicked
|
||||
vi.mock('@/components/SettingsUsers/SettingsUsersTable.vue', () => ({
|
||||
default: defineComponent({
|
||||
setup(_, { emit }) {
|
||||
addEmitter('settingsUsersTable', emit);
|
||||
},
|
||||
},
|
||||
[STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: true } } },
|
||||
};
|
||||
|
||||
const getInitialState = (state: TestingOptions['initialState'] = {}) =>
|
||||
merge({}, initialState, state);
|
||||
|
||||
const copy = vi.fn();
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copy,
|
||||
template: '<div />',
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderView = createComponentRenderer(SettingsUsersView);
|
||||
|
||||
const triggerUserAction = async (userListItem: HTMLElement, action: string) => {
|
||||
expect(userListItem).toBeInTheDocument();
|
||||
|
||||
const actionToggle = within(userListItem).getByTestId('action-toggle');
|
||||
const actionToggleButton = within(actionToggle).getByRole('button');
|
||||
expect(actionToggleButton).toBeVisible();
|
||||
|
||||
await userEvent.click(actionToggle);
|
||||
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
|
||||
|
||||
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
|
||||
await userEvent.click(within(actionDropdown).getByTestId(`action-${action}`));
|
||||
const mockToast = {
|
||||
showToast: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
};
|
||||
|
||||
const mockClipboard = {
|
||||
copy: vi.fn(),
|
||||
};
|
||||
|
||||
const mockPageRedirectionHelper = {
|
||||
goToUpgrade: vi.fn(),
|
||||
};
|
||||
|
||||
const showToast = vi.fn();
|
||||
const showError = vi.fn();
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showToast,
|
||||
showError,
|
||||
}),
|
||||
useToast: vi.fn(() => mockToast),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||
const goToUpgrade = vi.fn();
|
||||
return {
|
||||
usePageRedirectionHelper: () => ({
|
||||
goToUpgrade,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: vi.fn(() => mockClipboard),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: vi.fn(() => ({
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/usePageRedirectionHelper', () => ({
|
||||
usePageRedirectionHelper: vi.fn(() => mockPageRedirectionHelper),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/rbac/permissions', () => ({
|
||||
hasPermission: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const mockUsersList: UsersList = {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
firstName: 'Admin',
|
||||
lastName: 'User',
|
||||
role: ROLE.Admin,
|
||||
isOwner: true,
|
||||
isPending: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'member@example.com',
|
||||
firstName: 'Member',
|
||||
lastName: 'User',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'pending@example.com',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: ROLE.Member,
|
||||
isOwner: false,
|
||||
isPending: true,
|
||||
settings: {},
|
||||
inviteAcceptUrl: 'https://example.com/invite/123',
|
||||
},
|
||||
],
|
||||
count: 3,
|
||||
};
|
||||
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
let usersStore: MockedStore<typeof useUsersStore>;
|
||||
let uiStore: MockedStore<typeof useUIStore>;
|
||||
let settingsStore: MockedStore<typeof useSettingsStore>;
|
||||
let ssoStore: MockedStore<typeof useSSOStore>;
|
||||
|
||||
describe('SettingsUsersView', () => {
|
||||
beforeEach(() => {
|
||||
createAppModals();
|
||||
renderComponent = createComponentRenderer(SettingsUsersView, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
usersStore = mockedStore(useUsersStore);
|
||||
uiStore = mockedStore(useUIStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
ssoStore = mockedStore(useSSOStore);
|
||||
|
||||
// Setup default store states
|
||||
usersStore.usersLimitNotReached = true;
|
||||
usersStore.usersList = {
|
||||
state: mockUsersList,
|
||||
isLoading: false,
|
||||
execute: vi.fn(),
|
||||
isReady: true,
|
||||
error: null,
|
||||
then: vi.fn(),
|
||||
};
|
||||
usersStore.currentUserId = '1';
|
||||
usersStore.reinviteUser = vi.fn().mockResolvedValue(undefined);
|
||||
usersStore.getUserPasswordResetLink = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ link: 'https://example.com/reset/123' });
|
||||
usersStore.updateOtherUserSettings = vi.fn().mockResolvedValue(undefined);
|
||||
usersStore.updateGlobalRole = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
settingsStore.isSmtpSetup = true;
|
||||
settingsStore.settings.enterprise = {
|
||||
mfaEnforcement: true,
|
||||
} as FrontendSettings['enterprise'];
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.AdvancedPermissions] = true;
|
||||
ssoStore.isSamlLoginEnabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
copy.mockReset();
|
||||
showToast.mockReset();
|
||||
showError.mockReset();
|
||||
cleanupAppModals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('turn enforcing mfa on', async () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: getInitialState({
|
||||
settings: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
mfaEnforcement: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const userStore = mockedStore(useUsersStore);
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const actionSwitch = getByTestId('enable-force-mfa');
|
||||
expect(actionSwitch).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(actionSwitch);
|
||||
|
||||
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(true);
|
||||
expect(usersStore.updateEnforceMfa).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('turn enforcing mfa off', async () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: getInitialState({
|
||||
settings: {
|
||||
settings: {
|
||||
enterprise: {
|
||||
mfaEnforcement: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const userStore = mockedStore(useUsersStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isMFAEnforced = true;
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const actionSwitch = getByTestId('enable-force-mfa');
|
||||
expect(actionSwitch).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(actionSwitch);
|
||||
|
||||
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(false);
|
||||
expect(usersStore.updateEnforceMfa).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('hides invite button visibility based on user permissions', async () => {
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
const userStore = mockedStore(useUsersStore);
|
||||
userStore.currentUser = createUser({ isDefaultUser: true });
|
||||
it('should render correctly with users list', () => {
|
||||
renderComponent();
|
||||
|
||||
const { queryByTestId } = renderView({ pinia });
|
||||
|
||||
expect(queryByTestId('settings-users-invite-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /users/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('users-list-search')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('settings-users-invite-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Below quota', () => {
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
it('should open invite modal when invite button is clicked', async () => {
|
||||
renderComponent();
|
||||
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.usersLimitNotReached = false;
|
||||
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||
await userEvent.click(inviteButton);
|
||||
|
||||
it('disables the invite button', async () => {
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
expect(getByTestId('settings-users-invite-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('allows the user to upgrade', async () => {
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const actionBox = getByTestId('action-box');
|
||||
expect(actionBox).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(await within(actionBox).findByText('View plans'));
|
||||
|
||||
expect(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith(
|
||||
'settings-users',
|
||||
'upgrade-users',
|
||||
);
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: INVITE_USER_MODAL_KEY,
|
||||
data: {
|
||||
afterInvite: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the invite button on SAML login', async () => {
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
const ssoStore = useSSOStore(pinia);
|
||||
it('should disable invite button when SSO is enabled', () => {
|
||||
ssoStore.isSamlLoginEnabled = true;
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
renderComponent();
|
||||
|
||||
expect(getByTestId('settings-users-invite-button')).toBeDisabled();
|
||||
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||
expect(inviteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the invite modal', async () => {
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
it('should disable invite button when users limit is reached', () => {
|
||||
usersStore.usersLimitNotReached = false;
|
||||
|
||||
const uiStore = useUIStore();
|
||||
await userEvent.click(getByTestId('settings-users-invite-button'));
|
||||
renderComponent();
|
||||
|
||||
expect(uiStore.openModal).toHaveBeenCalledWith('inviteUser');
|
||||
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||
expect(inviteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows warning when advanced permissions are not enabled', async () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: getInitialState({
|
||||
[STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: false } } },
|
||||
}),
|
||||
it('should handle search input with debouncing', async () => {
|
||||
renderComponent();
|
||||
|
||||
const searchInput = screen.getByTestId('users-list-search');
|
||||
await userEvent.type(searchInput, 'test search');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(usersStore.usersList.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const { getByText } = renderView({ pinia });
|
||||
|
||||
expect(getByText('to unlock the ability to create additional admin users'));
|
||||
});
|
||||
|
||||
describe('per user actions', () => {
|
||||
it('should copy invite link to clipboard', async () => {
|
||||
const action = 'copyInviteLink';
|
||||
it('should show upgrade banner when users limit is reached', () => {
|
||||
usersStore.usersLimitNotReached = false;
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
expect(getByTestId('action-box')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
|
||||
it('should show advanced permissions notice when feature is disabled', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.AdvancedPermissions] = false;
|
||||
|
||||
expect(copy).toHaveBeenCalledWith(invitedUser.inviteAcceptUrl);
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
renderComponent();
|
||||
|
||||
expect(screen.getByTestId('upgrade-permissions-link')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId('upgrade-permissions-link'));
|
||||
|
||||
expect(mockPageRedirectionHelper.goToUpgrade).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show users table when limit is reached and only one user exists', () => {
|
||||
usersStore.usersLimitNotReached = false;
|
||||
usersStore.usersList.state = {
|
||||
...mockUsersList,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
expect(queryByTestId('settings-users-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show users table when limit is reached but multiple users exist', () => {
|
||||
usersStore.usersLimitNotReached = false;
|
||||
usersStore.usersList.state = {
|
||||
...mockUsersList,
|
||||
count: 3,
|
||||
};
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
// The users container should be visible when there are multiple users
|
||||
expect(getByTestId('settings-users-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('user actions', () => {
|
||||
it('should handle delete user action', async () => {
|
||||
renderComponent();
|
||||
|
||||
emitters.settingsUsersTable.emit('action', { action: 'delete', userId: '2' });
|
||||
|
||||
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||
name: DELETE_USER_MODAL_KEY,
|
||||
data: {
|
||||
userId: '2',
|
||||
afterDelete: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should re invite users', async () => {
|
||||
const action = 'reinvite';
|
||||
it('should handle reinvite user action', async () => {
|
||||
renderComponent();
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
emitters.settingsUsersTable.emit('action', { action: 'reinvite', userId: '3' });
|
||||
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
settingsStore.isSmtpSetup = true;
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
|
||||
|
||||
expect(userStore.reinviteUser).toHaveBeenCalled();
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
expect(usersStore.reinviteUser).toHaveBeenCalledWith({
|
||||
email: 'pending@example.com',
|
||||
role: ROLE.Member,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show delete users modal with the right permissions', async () => {
|
||||
const action = 'delete';
|
||||
it('should handle copy invite link action', async () => {
|
||||
renderComponent();
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
emitters.settingsUsersTable.emit('action', { action: 'copyInviteLink', userId: '3' });
|
||||
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.hasScope.mockReturnValue(true);
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
|
||||
|
||||
const uiStore = useUIStore();
|
||||
expect(uiStore.openDeleteUserModal).toHaveBeenCalledWith(user.id);
|
||||
expect(mockClipboard.copy).toHaveBeenCalledWith('https://example.com/invite/123');
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow coping reset password link', async () => {
|
||||
const action = 'copyPasswordResetLink';
|
||||
it('should handle copy password reset link action', async () => {
|
||||
renderComponent();
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
emitters.settingsUsersTable.emit('action', { action: 'copyPasswordResetLink', userId: '2' });
|
||||
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.hasScope.mockReturnValue(true);
|
||||
|
||||
const userStore = mockedStore(useUsersStore);
|
||||
userStore.getUserPasswordResetLink.mockResolvedValue({ link: 'dummy-reset-password' });
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
|
||||
|
||||
expect(userStore.getUserPasswordResetLink).toHaveBeenCalledWith(user);
|
||||
|
||||
expect(copy).toHaveBeenCalledWith('dummy-reset-password');
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
expect(usersStore.getUserPasswordResetLink).toHaveBeenCalledWith(mockUsersList.items[1]);
|
||||
await waitFor(() => {
|
||||
expect(mockClipboard.copy).toHaveBeenCalledWith('https://example.com/reset/123');
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable SSO manual login', async () => {
|
||||
const action = 'allowSSOManualLogin';
|
||||
it('should handle allow SSO manual login action', async () => {
|
||||
renderComponent();
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
emitters.settingsUsersTable.emit('action', { action: 'allowSSOManualLogin', userId: '2' });
|
||||
|
||||
const ssoStore = useSSOStore(pinia);
|
||||
ssoStore.isSamlLoginEnabled = true;
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
|
||||
expect(userStore.updateOtherUserSettings).toHaveBeenCalledWith(user.id, {
|
||||
expect(usersStore.updateOtherUserSettings).toHaveBeenCalledWith('2', {
|
||||
allowSSOManualLogin: true,
|
||||
});
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable SSO manual login', async () => {
|
||||
const action = 'disallowSSOManualLogin';
|
||||
it('should handle disallow SSO manual login action', async () => {
|
||||
// Set user to have SSO manual login enabled
|
||||
usersStore.usersList.state.items[1].settings = { allowSSOManualLogin: true };
|
||||
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
renderComponent();
|
||||
|
||||
const ssoStore = useSSOStore(pinia);
|
||||
ssoStore.isSamlLoginEnabled = true;
|
||||
emitters.settingsUsersTable.emit('action', { action: 'disallowSSOManualLogin', userId: '2' });
|
||||
|
||||
const userStore = useUsersStore();
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
|
||||
await triggerUserAction(getByTestId(`user-list-item-${userWithDisabledSSO.email}`), action);
|
||||
|
||||
expect(userStore.updateOtherUserSettings).toHaveBeenCalledWith(userWithDisabledSSO.id, {
|
||||
expect(usersStore.updateOtherUserSettings).toHaveBeenCalledWith('2', {
|
||||
allowSSOManualLogin: false,
|
||||
});
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle reinvite user error', async () => {
|
||||
usersStore.reinviteUser = vi.fn().mockRejectedValue(new Error('Failed to reinvite'));
|
||||
|
||||
renderComponent();
|
||||
|
||||
emitters.settingsUsersTable.emit('action', { action: 'reinvite', userId: '3' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show success toast when changing a user's role", async () => {
|
||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
||||
describe('role updates', () => {
|
||||
it('should handle role update', async () => {
|
||||
renderComponent();
|
||||
|
||||
const rbacStore = mockedStore(useRBACStore);
|
||||
rbacStore.hasScope.mockReturnValue(true);
|
||||
emitters.settingsUsersTable.emit('update:role', { role: ROLE.Admin, userId: '2' });
|
||||
|
||||
const userStore = useUsersStore();
|
||||
expect(usersStore.updateGlobalRole).toHaveBeenCalledWith({
|
||||
id: '2',
|
||||
newRoleName: ROLE.Admin,
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
title: expect.any(String),
|
||||
message: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const { getByTestId } = renderView({ pinia });
|
||||
it('should handle role update error', async () => {
|
||||
usersStore.updateGlobalRole = vi.fn().mockRejectedValue(new Error('Failed to update role'));
|
||||
|
||||
const userListItem = getByTestId(`user-list-item-${invitedUser.email}`);
|
||||
expect(userListItem).toBeInTheDocument();
|
||||
renderComponent();
|
||||
|
||||
const roleSelect = within(userListItem).getByTestId('user-role-select');
|
||||
emitters.settingsUsersTable.emit('update:role', { role: ROLE.Admin, userId: '2' });
|
||||
await waitFor(() => {
|
||||
expect(mockToast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const roleDropdownItems = await getDropdownItems(roleSelect);
|
||||
await userEvent.click(roleDropdownItems[0]);
|
||||
describe('table functionality', () => {
|
||||
it('should handle table options update', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(userStore.updateGlobalRole).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newRoleName: 'global:member' }),
|
||||
);
|
||||
emitters.settingsUsersTable.emit('update:options', {
|
||||
page: 1,
|
||||
itemsPerPage: 20,
|
||||
sortBy: [{ id: 'role', desc: true }],
|
||||
});
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'success', message: expect.stringContaining('Member') }),
|
||||
);
|
||||
expect(usersStore.usersList.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 20,
|
||||
take: 20,
|
||||
sortBy: ['role:desc'],
|
||||
expand: ['projectRelations'],
|
||||
filter: {
|
||||
fullText: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should transform name sort to firstName, lastName, email', async () => {
|
||||
renderComponent();
|
||||
|
||||
emitters.settingsUsersTable.emit('update:options', {
|
||||
page: 0,
|
||||
itemsPerPage: 10,
|
||||
sortBy: [{ id: 'name', desc: false }],
|
||||
});
|
||||
|
||||
expect(usersStore.usersList.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
sortBy: ['firstName:asc', 'lastName:asc', 'email:asc'],
|
||||
expand: ['projectRelations'],
|
||||
filter: {
|
||||
fullText: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out invalid sort keys', async () => {
|
||||
renderComponent();
|
||||
|
||||
emitters.settingsUsersTable.emit('update:options', {
|
||||
page: 0,
|
||||
itemsPerPage: 10,
|
||||
sortBy: [{ id: 'invalidKey', desc: false }],
|
||||
});
|
||||
|
||||
expect(usersStore.usersList.execute).toHaveBeenCalledWith(0, {
|
||||
skip: 0,
|
||||
take: 10,
|
||||
sortBy: [], // Invalid keys should be filtered out
|
||||
expand: ['projectRelations'],
|
||||
filter: {
|
||||
fullText: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import { ROLE, type Role } from '@n8n/api-types';
|
||||
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY } from '@/constants';
|
||||
import type { InvitableRoleName, IUser } from '@/Interface';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import {
|
||||
ROLE,
|
||||
type Role,
|
||||
type UsersListSortOptions,
|
||||
type User,
|
||||
USERS_LIST_SORT_OPTIONS,
|
||||
} from '@n8n/api-types';
|
||||
import type { UserAction } from '@n8n/design-system';
|
||||
import type { TableOptions } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import {
|
||||
DELETE_USER_MODAL_KEY,
|
||||
EnterpriseEditionFeature,
|
||||
INVITE_USER_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import type { InvitableRoleName, IUser } from '@/Interface';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@@ -10,11 +23,10 @@ import { useUsersStore } from '@/stores/users.store';
|
||||
import { useSSOStore } from '@/stores/sso.store';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import type { UpdateGlobalRolePayload } from '@/api/users';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
import SettingsUsersTable from '@/components/SettingsUsers/SettingsUsersTable.vue';
|
||||
|
||||
const clipboard = useClipboard();
|
||||
const { showToast, showError } = useToast();
|
||||
@@ -30,17 +42,23 @@ const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const showUMSetupWarning = computed(() => {
|
||||
return hasPermission(['defaultUser']);
|
||||
const search = ref('');
|
||||
const usersTableState = ref<TableOptions>({
|
||||
page: 0,
|
||||
itemsPerPage: 10,
|
||||
sortBy: [
|
||||
{ id: 'firstName', desc: false },
|
||||
{ id: 'lastName', desc: false },
|
||||
{ id: 'email', desc: false },
|
||||
],
|
||||
});
|
||||
|
||||
const allUsers = computed(() => usersStore.allUsers);
|
||||
const showUMSetupWarning = computed(() => hasPermission(['defaultUser']));
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.users'));
|
||||
|
||||
if (!showUMSetupWarning.value) {
|
||||
await usersStore.fetchUsers();
|
||||
await updateUsersTableData(usersTableState.value);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -57,13 +75,6 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
|
||||
guard: (user) =>
|
||||
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'),
|
||||
value: 'copyPasswordResetLink',
|
||||
@@ -85,9 +96,9 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
|
||||
},
|
||||
];
|
||||
});
|
||||
const isAdvancedPermissionsEnabled = computed((): boolean => {
|
||||
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
|
||||
});
|
||||
const isAdvancedPermissionsEnabled = computed(
|
||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions],
|
||||
);
|
||||
|
||||
const userRoles = computed((): Array<{ value: Role; label: string; disabled?: boolean }> => {
|
||||
return [
|
||||
@@ -103,10 +114,6 @@ const userRoles = computed((): Array<{ value: Role; label: string; disabled?: bo
|
||||
];
|
||||
});
|
||||
|
||||
const canUpdateRole = computed((): boolean => {
|
||||
return hasPermission(['rbac'], { rbac: { scope: ['user:update', 'user:changeRole'] } });
|
||||
});
|
||||
|
||||
async function onUsersListAction({ action, userId }: { action: string; userId: string }) {
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
@@ -130,16 +137,28 @@ async function onUsersListAction({ action, userId }: { action: string; userId: s
|
||||
}
|
||||
}
|
||||
function onInvite() {
|
||||
uiStore.openModal(INVITE_USER_MODAL_KEY);
|
||||
uiStore.openModalWithData({
|
||||
name: INVITE_USER_MODAL_KEY,
|
||||
data: {
|
||||
afterInvite: async () => {
|
||||
await updateUsersTableData(usersTableState.value);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async function onDelete(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
if (user) {
|
||||
uiStore.openDeleteUserModal(userId);
|
||||
}
|
||||
uiStore.openModalWithData({
|
||||
name: DELETE_USER_MODAL_KEY,
|
||||
data: {
|
||||
userId,
|
||||
afterDelete: async () => {
|
||||
await updateUsersTableData(usersTableState.value);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async function onReinvite(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === userId);
|
||||
if (user?.email && user?.role) {
|
||||
if (!['global:admin', 'global:member'].includes(user.role)) {
|
||||
throw new Error('Invalid role name on reinvite');
|
||||
@@ -162,7 +181,7 @@ async function onReinvite(userId: string) {
|
||||
}
|
||||
}
|
||||
async function onCopyInviteLink(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === userId);
|
||||
if (user?.inviteAcceptUrl) {
|
||||
void clipboard.copy(user.inviteAcceptUrl);
|
||||
|
||||
@@ -174,7 +193,7 @@ async function onCopyInviteLink(userId: string) {
|
||||
}
|
||||
}
|
||||
async function onCopyPasswordResetLink(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === userId);
|
||||
if (user) {
|
||||
const url = await usersStore.getUserPasswordResetLink(user);
|
||||
void clipboard.copy(url.link);
|
||||
@@ -187,13 +206,14 @@ async function onCopyPasswordResetLink(userId: string) {
|
||||
}
|
||||
}
|
||||
async function onAllowSSOManualLogin(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === userId);
|
||||
if (user) {
|
||||
if (!user.settings) {
|
||||
user.settings = {};
|
||||
}
|
||||
user.settings.allowSSOManualLogin = true;
|
||||
await usersStore.updateOtherUserSettings(userId, user.settings);
|
||||
await updateUsersTableData(usersTableState.value);
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
@@ -203,10 +223,12 @@ async function onAllowSSOManualLogin(userId: string) {
|
||||
}
|
||||
}
|
||||
async function onDisallowSSOManualLogin(userId: string) {
|
||||
const user = usersStore.usersById[userId];
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === userId);
|
||||
if (user?.settings) {
|
||||
user.settings.allowSSOManualLogin = false;
|
||||
await usersStore.updateOtherUserSettings(userId, user.settings);
|
||||
await updateUsersTableData(usersTableState.value);
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.users.disallowSSOManualLogin'),
|
||||
@@ -220,7 +242,18 @@ function goToUpgrade() {
|
||||
function goToUpgradeAdvancedPermissions() {
|
||||
void pageRedirectionHelper.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
||||
}
|
||||
async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['newRoleName']) {
|
||||
|
||||
const onUpdateRole = async (payload: { userId: string; role: Role }) => {
|
||||
const user = usersStore.usersList.state.items.find((u) => u.id === payload.userId);
|
||||
if (!user) {
|
||||
showError(new Error('User not found'), i18n.baseText('settings.users.userNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
await onRoleChange(user, payload.role);
|
||||
};
|
||||
|
||||
async function onRoleChange(user: User, newRoleName: Role) {
|
||||
try {
|
||||
await usersStore.updateGlobalRole({ id: user.id, newRoleName });
|
||||
|
||||
@@ -231,7 +264,10 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
|
||||
title: i18n.baseText('settings.users.userRoleUpdated'),
|
||||
message: i18n.baseText('settings.users.userRoleUpdated.message', {
|
||||
interpolate: {
|
||||
user: user.fullName ?? '',
|
||||
user:
|
||||
user.firstName && user.lastName
|
||||
? `${user.firstName} ${user.lastName}`
|
||||
: (user.email ?? ''),
|
||||
role,
|
||||
},
|
||||
}),
|
||||
@@ -241,6 +277,49 @@ async function onRoleChange(user: IUser, newRoleName: UpdateGlobalRolePayload['n
|
||||
}
|
||||
}
|
||||
|
||||
const isValidSortKey = (key: string): key is UsersListSortOptions =>
|
||||
(USERS_LIST_SORT_OPTIONS as readonly string[]).includes(key);
|
||||
|
||||
const updateUsersTableData = async ({ page, itemsPerPage, sortBy }: TableOptions) => {
|
||||
usersTableState.value = {
|
||||
page,
|
||||
itemsPerPage,
|
||||
sortBy,
|
||||
};
|
||||
|
||||
const skip = page * itemsPerPage;
|
||||
const take = itemsPerPage;
|
||||
|
||||
const transformedSortBy = sortBy
|
||||
.flatMap(({ id, desc }) => {
|
||||
const dir = desc ? 'desc' : 'asc';
|
||||
if (id === 'name') {
|
||||
return [`firstName:${dir}`, `lastName:${dir}`, `email:${dir}`];
|
||||
}
|
||||
return `${id}:${dir}`;
|
||||
})
|
||||
.filter(isValidSortKey);
|
||||
|
||||
await usersStore.usersList.execute(0, {
|
||||
skip,
|
||||
take,
|
||||
sortBy: transformedSortBy,
|
||||
expand: ['projectRelations'],
|
||||
filter: {
|
||||
fullText: search.value.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedUpdateUsersTableData = useDebounceFn(() => {
|
||||
void updateUsersTableData(usersTableState.value);
|
||||
}, 300);
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
search.value = value;
|
||||
void debouncedUpdateUsersTableData();
|
||||
};
|
||||
|
||||
async function onUpdateMfaEnforced(value: boolean) {
|
||||
try {
|
||||
await usersStore.updateEnforceMfa(value);
|
||||
@@ -261,25 +340,9 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div>
|
||||
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.users') }}</n8n-heading>
|
||||
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
|
||||
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||
<template #content>
|
||||
<span> {{ i18n.baseText('settings.users.invite.tooltip') }} </span>
|
||||
</template>
|
||||
<div>
|
||||
<n8n-button
|
||||
:disabled="ssoStore.isSamlLoginEnabled || !usersStore.usersLimitNotReached"
|
||||
:label="i18n.baseText('settings.users.invite')"
|
||||
size="large"
|
||||
data-test-id="settings-users-invite-button"
|
||||
@click="onInvite"
|
||||
/>
|
||||
</div>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<n8n-heading tag="h1" size="2xlarge" class="mb-xl">
|
||||
{{ i18n.baseText('settings.users') }}
|
||||
</n8n-heading>
|
||||
<div v-if="!usersStore.usersLimitNotReached" :class="$style.setupInfoContainer">
|
||||
<n8n-action-box
|
||||
:heading="
|
||||
@@ -297,7 +360,11 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
||||
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||
<template #link>
|
||||
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||
<n8n-link
|
||||
data-test-id="upgrade-permissions-link"
|
||||
size="small"
|
||||
@click="goToUpgradeAdvancedPermissions"
|
||||
>
|
||||
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||
</n8n-link>
|
||||
</template>
|
||||
@@ -340,76 +407,74 @@ async function onUpdateMfaEnforced(value: boolean) {
|
||||
</EnterpriseEdition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
|
||||
<n8n-input
|
||||
:class="$style.search"
|
||||
:model-value="search"
|
||||
:placeholder="i18n.baseText('settings.users.search.placeholder')"
|
||||
clearable
|
||||
data-test-id="users-list-search"
|
||||
@update:model-value="onSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<n8n-icon icon="search" />
|
||||
</template>
|
||||
</n8n-input>
|
||||
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
|
||||
<template #content>
|
||||
<span> {{ i18n.baseText('settings.users.invite.tooltip') }} </span>
|
||||
</template>
|
||||
<div>
|
||||
<n8n-button
|
||||
:disabled="ssoStore.isSamlLoginEnabled || !usersStore.usersLimitNotReached"
|
||||
:label="i18n.baseText('settings.users.invite')"
|
||||
size="large"
|
||||
data-test-id="settings-users-invite-button"
|
||||
@click="onInvite"
|
||||
/>
|
||||
</div>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
<!-- If there's more than 1 user it means the account quota was more than 1 in the past. So we need to allow instance owner to be able to delete users and transfer workflows.
|
||||
-->
|
||||
<div
|
||||
v-if="usersStore.usersLimitNotReached || allUsers.length > 1"
|
||||
v-if="usersStore.usersLimitNotReached || usersStore.usersList.state.count > 1"
|
||||
:class="$style.usersContainer"
|
||||
>
|
||||
<n8n-users-list
|
||||
<SettingsUsersTable
|
||||
data-test-id="settings-users-table"
|
||||
:data="usersStore.usersList.state"
|
||||
:loading="usersStore.usersList.isLoading"
|
||||
:actions="usersListActions"
|
||||
:users="allUsers"
|
||||
:current-user-id="usersStore.currentUserId"
|
||||
:is-saml-login-enabled="ssoStore.isSamlLoginEnabled"
|
||||
@update:options="updateUsersTableData"
|
||||
@update:role="onUpdateRole"
|
||||
@action="onUsersListAction"
|
||||
>
|
||||
<template #actions="{ user }">
|
||||
<n8n-select
|
||||
v-if="user.id !== usersStore.currentUserId"
|
||||
:model-value="user?.role || 'global:member'"
|
||||
:disabled="!canUpdateRole"
|
||||
data-test-id="user-role-select"
|
||||
@update:model-value="onRoleChange(user, $event)"
|
||||
>
|
||||
<n8n-option
|
||||
v-for="role in userRoles"
|
||||
:key="role.value"
|
||||
:value="role.value"
|
||||
:label="role.label"
|
||||
:disabled="role.disabled"
|
||||
/>
|
||||
</n8n-select>
|
||||
</template>
|
||||
</n8n-users-list>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
height: 100%;
|
||||
padding-right: var(--spacing-2xs);
|
||||
|
||||
> * {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
.usersContainer {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin-bottom: var(--spacing-l);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-s);
|
||||
margin: 0 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.setupInfoContainer {
|
||||
max-width: 728px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
left: calc(50% + 100px);
|
||||
}
|
||||
|
||||
.settingsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 16px;
|
||||
padding-left: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-l);
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ onMounted(() => {
|
||||
.content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-width: 1440px;
|
||||
padding: 0 var(--spacing-2xl);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user