refactor(editor): Update users list on user settings page (#16244)

Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
Csaba Tuncsik
2025-07-03 13:57:14 +02:00
committed by GitHub
parent 50d80ee620
commit 19ac32659f
27 changed files with 1659 additions and 446 deletions

View File

@@ -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');
});

View File

@@ -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');
},
};

View File

@@ -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';

View File

@@ -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, {

View File

@@ -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',
},
},
{

View File

@@ -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 }];
}>();

View File

@@ -1,3 +1,4 @@
import N8nDataTableServer from './N8nDataTableServer.vue';
export default N8nDataTableServer;
export type { TableOptions, TableHeader } from './N8nDataTableServer.vue';

View File

@@ -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>

View File

@@ -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",

View File

@@ -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 };
},
};
};

View File

@@ -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(

View File

@@ -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,
});
});

View File

@@ -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'));

View File

@@ -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'));
}

View File

@@ -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>

View File

@@ -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' }]);
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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' }]);
});
});

View File

@@ -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>

View File

@@ -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' }]);
});
});
});

View File

@@ -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>

View File

@@ -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,
};
});

View File

@@ -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: '',
},
});
});
});
});

View File

@@ -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;

View File

@@ -63,7 +63,7 @@ onMounted(() => {
.content {
height: 100%;
width: 100%;
max-width: 800px;
max-width: 1440px;
padding: 0 var(--spacing-2xl);
}
</style>