mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +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 { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../pages';
|
||||||
import { errorToast, successToast } from '../pages/notifications';
|
import { errorToast, successToast } from '../pages/notifications';
|
||||||
import { PersonalSettingsPage } from '../pages/settings-personal';
|
import { PersonalSettingsPage } from '../pages/settings-personal';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisiblePopper } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User A - Instance owner
|
* User A - Instance owner
|
||||||
@@ -74,8 +74,8 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
|||||||
// List item for current user should have the `Owner` badge
|
// List item for current user should have the `Owner` badge
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userItem(INSTANCE_OWNER.email)
|
.userItem(INSTANCE_OWNER.email)
|
||||||
.find('.n8n-badge:contains("Owner")')
|
.find('td:contains("Owner")')
|
||||||
.should('exist');
|
.should('be.visible');
|
||||||
// Other users list items should contain action pop-up list
|
// Other users list items should contain action pop-up list
|
||||||
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[0].email).should('exist');
|
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[0].email).should('exist');
|
||||||
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[1].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
|
// Change role from Member to Admin
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
.find('input')
|
.find('button:contains("Member")')
|
||||||
.should('contain.value', 'Member');
|
.should('be.visible')
|
||||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_MEMBERS[0].email).click();
|
.click();
|
||||||
getVisibleSelect().find('li').contains('Admin').click();
|
getVisiblePopper().find('label').contains('Admin').click();
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
.find('input')
|
.find('button:contains("Admin")')
|
||||||
.should('contain.value', 'Admin');
|
.should('be.visible');
|
||||||
|
|
||||||
usersSettingsPage.actions.loginAndVisit(
|
usersSettingsPage.actions.loginAndVisit(
|
||||||
INSTANCE_MEMBERS[0].email,
|
INSTANCE_MEMBERS[0].email,
|
||||||
@@ -108,15 +108,14 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
|||||||
// Change role from Admin to Member, then back to Admin
|
// Change role from Admin to Member, then back to Admin
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
.find('input')
|
.find('button:contains("Admin")')
|
||||||
.should('contain.value', 'Admin');
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
getVisiblePopper().find('label').contains('Member').click();
|
||||||
getVisibleSelect().find('li').contains('Member').click();
|
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
.find('input')
|
.find('button:contains("Member")')
|
||||||
.should('contain.value', 'Member');
|
.should('be.visible');
|
||||||
|
|
||||||
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
|
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
|
||||||
usersSettingsPage.actions.loginAndVisit(
|
usersSettingsPage.actions.loginAndVisit(
|
||||||
@@ -125,20 +124,28 @@ describe('User Management', { disableAutoLogin: true }, () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
usersSettingsPage.getters.userRoleSelect(INSTANCE_ADMIN.email).click();
|
|
||||||
getVisibleSelect().find('li').contains('Admin').click();
|
|
||||||
usersSettingsPage.getters
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_ADMIN.email)
|
.userRoleSelect(INSTANCE_ADMIN.email)
|
||||||
.find('input')
|
.find('button:contains("Member")')
|
||||||
.should('contain.value', 'Admin');
|
.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.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
|
usersSettingsPage.getters
|
||||||
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
.userRoleSelect(INSTANCE_MEMBERS[0].email)
|
||||||
.find('input')
|
.find('button:contains("Admin")')
|
||||||
.should('contain.value', 'Member');
|
.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');
|
cy.disableFeature('advancedPermissions');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MainSidebar } from './sidebar/main-sidebar';
|
|||||||
import { SettingsSidebar } from './sidebar/settings-sidebar';
|
import { SettingsSidebar } from './sidebar/settings-sidebar';
|
||||||
import { WorkflowPage } from './workflow';
|
import { WorkflowPage } from './workflow';
|
||||||
import { WorkflowsPage } from './workflows';
|
import { WorkflowsPage } from './workflows';
|
||||||
|
import { getVisiblePopper } from '../utils';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const workflowsPage = new WorkflowsPage();
|
const workflowsPage = new WorkflowsPage();
|
||||||
@@ -25,12 +26,12 @@ export class SettingsUsersPage extends BasePage {
|
|||||||
inviteButton: () => cy.getByTestId('settings-users-invite-button').last(),
|
inviteButton: () => cy.getByTestId('settings-users-invite-button').last(),
|
||||||
inviteUsersModal: () => cy.getByTestId('inviteUser-modal').last(),
|
inviteUsersModal: () => cy.getByTestId('inviteUser-modal').last(),
|
||||||
inviteUsersModalEmailsInput: () => cy.getByTestId('emails').find('input').first(),
|
inviteUsersModalEmailsInput: () => cy.getByTestId('emails').find('input').first(),
|
||||||
userListItems: () => cy.get('[data-test-id^="user-list-item"]'),
|
userListItems: () => cy.get('[data-test-id="settings-users-table"] tbody tr'),
|
||||||
userItem: (email: string) => cy.getByTestId(`user-list-item-${email.toLowerCase()}`),
|
userItem: (email: string) => this.getters.userListItems().contains(email).closest('tr'),
|
||||||
userActionsToggle: (email: string) =>
|
userActionsToggle: (email: string) =>
|
||||||
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
|
this.getters.userItem(email).find('[data-test-id="action-toggle"]'),
|
||||||
userRoleSelect: (email: string) =>
|
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: () =>
|
deleteUserAction: () =>
|
||||||
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
cy.getByTestId('action-toggle-dropdown').find('li:contains("Delete"):visible'),
|
||||||
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
|
confirmDeleteModal: () => cy.getByTestId('deleteUser-modal').last(),
|
||||||
@@ -61,8 +62,8 @@ export class SettingsUsersPage extends BasePage {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
opedDeleteDialog: (email: string) => {
|
opedDeleteDialog: (email: string) => {
|
||||||
this.getters.userActionsToggle(email).click();
|
this.getters.userRoleSelect(email).find('button').should('be.visible').click();
|
||||||
this.getters.deleteUserAction().realClick();
|
getVisiblePopper().find('span').contains('Remove user').click();
|
||||||
this.getters.confirmDeleteModal().should('be.visible');
|
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 { InsightsDateFilterDto } from './insights/date-filter.dto';
|
||||||
|
|
||||||
export { PaginationDto } from './pagination/pagination.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';
|
export { OidcConfigDto } from './oidc/config.dto';
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Z } from 'zod-class';
|
|||||||
|
|
||||||
import { createTakeValidator, paginationSchema } from '../pagination/pagination.dto';
|
import { createTakeValidator, paginationSchema } from '../pagination/pagination.dto';
|
||||||
|
|
||||||
const USERS_LIST_SORT_OPTIONS = [
|
export const USERS_LIST_SORT_OPTIONS = [
|
||||||
'firstName:asc',
|
'firstName:asc',
|
||||||
'firstName:desc',
|
'firstName:desc',
|
||||||
'lastName:asc',
|
'lastName:asc',
|
||||||
'lastName:desc',
|
'lastName:desc',
|
||||||
|
'email:asc',
|
||||||
|
'email:desc',
|
||||||
'role:asc', // ascending order by role is Owner, Admin, Member
|
'role:asc', // ascending order by role is Owner, Admin, Member
|
||||||
'role:desc',
|
'role:desc',
|
||||||
'mfaEnabled:asc',
|
'mfaEnabled:asc',
|
||||||
@@ -17,6 +19,8 @@ const USERS_LIST_SORT_OPTIONS = [
|
|||||||
// 'lastActive:desc',
|
// 'lastActive:desc',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type UsersListSortOptions = (typeof USERS_LIST_SORT_OPTIONS)[number];
|
||||||
|
|
||||||
const usersListSortByValidator = z
|
const usersListSortByValidator = z
|
||||||
.array(
|
.array(
|
||||||
z.enum(USERS_LIST_SORT_OPTIONS, {
|
z.enum(USERS_LIST_SORT_OPTIONS, {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const frontendConfig = tseslint.config(
|
|||||||
files: ['**/*.test.ts', '**/test/**/*.ts', '**/__tests__/**/*.ts', '**/*.stories.ts'],
|
files: ['**/*.test.ts', '**/test/**/*.ts', '**/__tests__/**/*.ts', '**/*.stories.ts'],
|
||||||
rules: {
|
rules: {
|
||||||
'import-x/no-extraneous-dependencies': 'warn',
|
'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> }
|
| { key: string; value: AccessorFn<T> }
|
||||||
);
|
);
|
||||||
export type TableSortBy = SortingState;
|
export type TableSortBy = SortingState;
|
||||||
|
export type TableOptions = {
|
||||||
|
page: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
sortBy: Array<{ id: string; desc: boolean }>;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||||
@@ -72,13 +77,7 @@ defineSlots<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'update:options': [
|
'update:options': [payload: TableOptions];
|
||||||
payload: {
|
|
||||||
page: number;
|
|
||||||
itemsPerPage: number;
|
|
||||||
sortBy: Array<{ id: string; desc: boolean }>;
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'click:row': [event: MouseEvent, payload: { item: T }];
|
'click:row': [event: MouseEvent, payload: { item: T }];
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
import N8nDataTableServer from './N8nDataTableServer.vue';
|
import N8nDataTableServer from './N8nDataTableServer.vue';
|
||||||
|
|
||||||
export default N8nDataTableServer;
|
export default N8nDataTableServer;
|
||||||
|
export type { TableOptions, TableHeader } from './N8nDataTableServer.vue';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import N8nAvatar from '../N8nAvatar';
|
|||||||
import N8nBadge from '../N8nBadge';
|
import N8nBadge from '../N8nBadge';
|
||||||
import N8nText from '../N8nText';
|
import N8nText from '../N8nText';
|
||||||
|
|
||||||
interface UsersInfoProps {
|
export interface UsersInfoProps {
|
||||||
firstName?: string | null;
|
firstName?: string | null;
|
||||||
lastName?: string | null;
|
lastName?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
@@ -56,13 +56,6 @@ const classes = computed(
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<N8nText data-test-id="user-email" size="small" color="text-light">{{ email }}</N8nText>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
"error": "Error"
|
"user": "User",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
},
|
},
|
||||||
"_reusableDynamicText": {
|
"_reusableDynamicText": {
|
||||||
"readMore": "Read more",
|
"readMore": "Read more",
|
||||||
@@ -1930,6 +1932,7 @@
|
|||||||
"settings.personal.security": "Security",
|
"settings.personal.security": "Security",
|
||||||
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",
|
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",
|
||||||
"settings.users": "Users",
|
"settings.users": "Users",
|
||||||
|
"settings.users.search.placeholder": "Search by name or email",
|
||||||
"settings.users.confirmDataHandlingAfterDeletion": "What should we do with their data?",
|
"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.confirmUserDeletion": "Are you sure you want to delete this invited user?",
|
||||||
"settings.users.delete": "Delete",
|
"settings.users.delete": "Delete",
|
||||||
@@ -1978,6 +1981,7 @@
|
|||||||
"settings.users.transferWorkflowsAndCredentials.user": "User or project to transfer to",
|
"settings.users.transferWorkflowsAndCredentials.user": "User or project to transfer to",
|
||||||
"settings.users.transferWorkflowsAndCredentials.placeholder": "Select project or user",
|
"settings.users.transferWorkflowsAndCredentials.placeholder": "Select project or user",
|
||||||
"settings.users.transferredToUser": "Data transferred to {projectName}",
|
"settings.users.transferredToUser": "Data transferred to {projectName}",
|
||||||
|
"settings.users.userNotFound": "User not found",
|
||||||
"settings.users.userDeleted": "User deleted",
|
"settings.users.userDeleted": "User deleted",
|
||||||
"settings.users.userDeletedError": "Problem while deleting user",
|
"settings.users.userDeletedError": "Problem while deleting user",
|
||||||
"settings.users.userInvited": "User invited",
|
"settings.users.userInvited": "User invited",
|
||||||
@@ -1992,6 +1996,17 @@
|
|||||||
"settings.users.userRoleUpdated": "Changes saved",
|
"settings.users.userRoleUpdated": "Changes saved",
|
||||||
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
|
"settings.users.userRoleUpdated.message": "{user} has been successfully updated to a {role}",
|
||||||
"settings.users.userRoleUpdatedError": "Unable to updated 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": "API",
|
||||||
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
|
"settings.api.scopes.upgrade": "{link} to unlock the ability to modify API key scopes",
|
||||||
"settings.api.scopes.upgrade.link": "Upgrade",
|
"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 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 {
|
import type {
|
||||||
LoginRequestDto,
|
LoginRequestDto,
|
||||||
PasswordUpdateRequestDto,
|
PasswordUpdateRequestDto,
|
||||||
|
Role,
|
||||||
SettingsUpdateRequestDto,
|
SettingsUpdateRequestDto,
|
||||||
UsersList,
|
UsersList,
|
||||||
UsersListFilterDto,
|
UsersListFilterDto,
|
||||||
@@ -10,7 +11,6 @@ import type {
|
|||||||
CurrentUserResponse,
|
CurrentUserResponse,
|
||||||
IPersonalizationLatestVersion,
|
IPersonalizationLatestVersion,
|
||||||
IUserResponse,
|
IUserResponse,
|
||||||
InvitableRoleName,
|
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||||
import type { IDataObject, IUserSettings } from 'n8n-workflow';
|
import type { IDataObject, IUserSettings } from 'n8n-workflow';
|
||||||
@@ -158,7 +158,7 @@ export async function submitPersonalizationSurvey(
|
|||||||
|
|
||||||
export interface UpdateGlobalRolePayload {
|
export interface UpdateGlobalRolePayload {
|
||||||
id: string;
|
id: string;
|
||||||
newRoleName: InvitableRoleName;
|
newRoleName: Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGlobalRole(
|
export async function updateGlobalRole(
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { type MockedStore, mockedStore } from '@/__tests__/utils';
|
||||||
import DeleteUserModal from './DeleteUserModal.vue';
|
import DeleteUserModal from './DeleteUserModal.vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { getDropdownItems } from '@/__tests__/utils';
|
import { getDropdownItems } from '@/__tests__/utils';
|
||||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||||
import { createUser } from '@/__tests__/data/users';
|
|
||||||
|
|
||||||
import { DELETE_USER_MODAL_KEY } from '@/constants';
|
import { DELETE_USER_MODAL_KEY } from '@/constants';
|
||||||
import { STORES } from '@n8n/stores';
|
import { STORES } from '@n8n/stores';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { ROLE, type UsersList, type User } from '@n8n/api-types';
|
||||||
|
|
||||||
const ModalStub = {
|
const ModalStub = {
|
||||||
template: `
|
template: `
|
||||||
@@ -22,9 +22,42 @@ const ModalStub = {
|
|||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const loggedInUser = createUser();
|
const loggedInUser: User = {
|
||||||
const invitedUser = createUser({ firstName: undefined });
|
id: '1',
|
||||||
const user = createUser();
|
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 = {
|
const initialState = {
|
||||||
[STORES.UI]: {
|
[STORES.UI]: {
|
||||||
@@ -43,13 +76,6 @@ const initialState = {
|
|||||||
ProjectTypes.Team,
|
ProjectTypes.Team,
|
||||||
].map(createProjectListItem),
|
].map(createProjectListItem),
|
||||||
},
|
},
|
||||||
[STORES.USERS]: {
|
|
||||||
usersById: {
|
|
||||||
[loggedInUser.id]: loggedInUser,
|
|
||||||
[user.id]: user,
|
|
||||||
[invitedUser.id]: invitedUser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const global = {
|
const global = {
|
||||||
@@ -60,32 +86,47 @@ const global = {
|
|||||||
|
|
||||||
const renderModal = createComponentRenderer(DeleteUserModal);
|
const renderModal = createComponentRenderer(DeleteUserModal);
|
||||||
let pinia: ReturnType<typeof createTestingPinia>;
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
|
let usersStore: MockedStore<typeof useUsersStore>;
|
||||||
|
|
||||||
describe('DeleteUserModal', () => {
|
describe('DeleteUserModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pinia = createTestingPinia({ initialState });
|
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 () => {
|
it('should delete invited users', async () => {
|
||||||
const { getByTestId } = renderModal({
|
const { getByTestId } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
activeId: invitedUser.id,
|
modalName: DELETE_USER_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
userId: invitedUser.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
global,
|
global,
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userStore = useUsersStore();
|
|
||||||
|
|
||||||
await userEvent.click(getByTestId('confirm-delete-user-button'));
|
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 () => {
|
it('should delete user and transfer workflows and credentials', async () => {
|
||||||
const { getByTestId, getAllByRole } = renderModal({
|
const { getByTestId, getAllByRole } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
activeId: user.id,
|
modalName: DELETE_USER_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
global,
|
global,
|
||||||
pinia,
|
pinia,
|
||||||
@@ -102,12 +143,10 @@ describe('DeleteUserModal', () => {
|
|||||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||||
await userEvent.click(projectSelectDropdownItems[0]);
|
await userEvent.click(projectSelectDropdownItems[0]);
|
||||||
|
|
||||||
const userStore = useUsersStore();
|
|
||||||
|
|
||||||
expect(confirmButton).toBeEnabled();
|
expect(confirmButton).toBeEnabled();
|
||||||
await userEvent.click(confirmButton);
|
await userEvent.click(confirmButton);
|
||||||
|
|
||||||
expect(userStore.deleteUser).toHaveBeenCalledWith({
|
expect(usersStore.deleteUser).toHaveBeenCalledWith({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
transferId: expect.any(String),
|
transferId: expect.any(String),
|
||||||
});
|
});
|
||||||
@@ -116,14 +155,15 @@ describe('DeleteUserModal', () => {
|
|||||||
it('should delete user without transfer', async () => {
|
it('should delete user without transfer', async () => {
|
||||||
const { getByTestId, getAllByRole, getByRole } = renderModal({
|
const { getByTestId, getAllByRole, getByRole } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
activeId: user.id,
|
modalName: DELETE_USER_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
global,
|
global,
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userStore = useUsersStore();
|
|
||||||
|
|
||||||
const confirmButton = getByTestId('confirm-delete-user-button');
|
const confirmButton = getByTestId('confirm-delete-user-button');
|
||||||
expect(confirmButton).toBeDisabled();
|
expect(confirmButton).toBeDisabled();
|
||||||
|
|
||||||
@@ -138,7 +178,7 @@ describe('DeleteUserModal', () => {
|
|||||||
expect(confirmButton).toBeEnabled();
|
expect(confirmButton).toBeEnabled();
|
||||||
|
|
||||||
await userEvent.click(confirmButton);
|
await userEvent.click(confirmButton);
|
||||||
expect(userStore.deleteUser).toHaveBeenCalledWith({
|
expect(usersStore.deleteUser).toHaveBeenCalledWith({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useI18n } from '@n8n/i18n';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
activeId: string;
|
data: {
|
||||||
|
userId: string;
|
||||||
|
afterDelete?: () => Promise<void>;
|
||||||
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const modalBus = createEventBus();
|
const modalBus = createEventBus();
|
||||||
@@ -25,17 +28,18 @@ const usersStore = useUsersStore();
|
|||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const userToDelete = computed(() => {
|
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(() => {
|
const isPending = computed(() => !userToDelete.value?.firstName);
|
||||||
return userToDelete.value ? !userToDelete.value.firstName : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const title = computed(() => {
|
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 } });
|
return i18n.baseText('settings.users.deleteUser', { interpolate: { user } });
|
||||||
});
|
});
|
||||||
@@ -52,11 +56,7 @@ const enabled = computed(() => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.value === 'transfer' && selectedProject.value) {
|
return !!(operation.value === 'transfer' && selectedProject.value);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const projects = computed(() => {
|
const projects = computed(() => {
|
||||||
@@ -81,7 +81,7 @@ async function onSubmit() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
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) {
|
if (operation.value === 'transfer' && selectedProject.value) {
|
||||||
params.transferId = selectedProject.value.id;
|
params.transferId = selectedProject.value.id;
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,7 @@ async function onSubmit() {
|
|||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await props.data.afterDelete?.();
|
||||||
modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, i18n.baseText('settings.users.userDeletedError'));
|
showError(error, i18n.baseText('settings.users.userDeletedError'));
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import { useClipboard } from '@/composables/useClipboard';
|
|||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
afterInvite?: () => Promise<void>;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
@@ -226,6 +233,8 @@ async function onSubmit() {
|
|||||||
} else {
|
} else {
|
||||||
modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await props.data.afterInvite?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, i18n.baseText('settings.users.usersInvitedError'));
|
showError(error, i18n.baseText('settings.users.usersInvitedError'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,12 +163,14 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
<ModalRoot :name="INVITE_USER_MODAL_KEY">
|
||||||
<InviteUsersModal />
|
<template #default="{ modalName, data }">
|
||||||
|
<InviteUsersModal :modal-name="modalName" :data="data" />
|
||||||
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="DELETE_USER_MODAL_KEY">
|
<ModalRoot :name="DELETE_USER_MODAL_KEY">
|
||||||
<template #default="{ modalName, activeId }">
|
<template #default="{ modalName, data }">
|
||||||
<DeleteUserModal :modal-name="modalName" :active-id="activeId" />
|
<DeleteUserModal :modal-name="modalName" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</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 {
|
import {
|
||||||
type LoginRequestDto,
|
type LoginRequestDto,
|
||||||
type PasswordUpdateRequestDto,
|
type PasswordUpdateRequestDto,
|
||||||
@@ -5,6 +6,7 @@ import {
|
|||||||
type UserUpdateRequestDto,
|
type UserUpdateRequestDto,
|
||||||
type User,
|
type User,
|
||||||
ROLE,
|
ROLE,
|
||||||
|
type UsersListFilterDto,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
import type { UpdateGlobalRolePayload } from '@/api/users';
|
import type { UpdateGlobalRolePayload } from '@/api/users';
|
||||||
import * as usersApi 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 updateOtherUserSettings = async (userId: string, settings: SettingsUpdateRequestDto) => {
|
||||||
const updatedSettings = await usersApi.updateOtherUserSettings(
|
await usersApi.updateOtherUserSettings(rootStore.restApiContext, userId, settings);
|
||||||
rootStore.restApiContext,
|
|
||||||
userId,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
usersById.value[userId].settings = updatedSettings;
|
|
||||||
addUsers([usersById.value[userId]]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateCurrentUserPassword = async (params: PasswordUpdateRequestDto) => {
|
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 {
|
return {
|
||||||
initialized,
|
initialized,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
@@ -480,5 +486,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
|
|||||||
setCalloutDismissed,
|
setCalloutDismissed,
|
||||||
submitContactEmail,
|
submitContactEmail,
|
||||||
submitContactInfo,
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { vi } from 'vitest';
|
||||||
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
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 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 { 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 { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useSSOStore } from '@/stores/sso.store';
|
import { useSSOStore } from '@/stores/sso.store';
|
||||||
import { STORES } from '@n8n/stores';
|
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
|
||||||
|
|
||||||
const loggedInUser = createUser();
|
const { emitters, addEmitter } = useEmitters<'settingsUsersTable'>();
|
||||||
const invitedUser = createUser({
|
|
||||||
firstName: undefined,
|
|
||||||
inviteAcceptUrl: 'dummy',
|
|
||||||
role: 'global:admin',
|
|
||||||
});
|
|
||||||
const user = createUser();
|
|
||||||
const userWithDisabledSSO = createUser({
|
|
||||||
settings: { allowSSOManualLogin: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialState = {
|
// Mock the SettingsUsersTable component to emit events when clicked
|
||||||
[STORES.USERS]: {
|
vi.mock('@/components/SettingsUsers/SettingsUsersTable.vue', () => ({
|
||||||
currentUserId: loggedInUser.id,
|
default: defineComponent({
|
||||||
usersById: {
|
setup(_, { emit }) {
|
||||||
[loggedInUser.id]: loggedInUser,
|
addEmitter('settingsUsersTable', emit);
|
||||||
[invitedUser.id]: invitedUser,
|
|
||||||
[user.id]: user,
|
|
||||||
[userWithDisabledSSO.id]: userWithDisabledSSO,
|
|
||||||
},
|
},
|
||||||
},
|
template: '<div />',
|
||||||
[STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: true } } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialState = (state: TestingOptions['initialState'] = {}) =>
|
|
||||||
merge({}, initialState, state);
|
|
||||||
|
|
||||||
const copy = vi.fn();
|
|
||||||
vi.mock('@/composables/useClipboard', () => ({
|
|
||||||
useClipboard: () => ({
|
|
||||||
copy,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderView = createComponentRenderer(SettingsUsersView);
|
const mockToast = {
|
||||||
|
showToast: vi.fn(),
|
||||||
const triggerUserAction = async (userListItem: HTMLElement, action: string) => {
|
showError: vi.fn(),
|
||||||
expect(userListItem).toBeInTheDocument();
|
};
|
||||||
|
|
||||||
const actionToggle = within(userListItem).getByTestId('action-toggle');
|
const mockClipboard = {
|
||||||
const actionToggleButton = within(actionToggle).getByRole('button');
|
copy: vi.fn(),
|
||||||
expect(actionToggleButton).toBeVisible();
|
};
|
||||||
|
|
||||||
await userEvent.click(actionToggle);
|
const mockPageRedirectionHelper = {
|
||||||
const actionToggleId = actionToggleButton.getAttribute('aria-controls');
|
goToUpgrade: vi.fn(),
|
||||||
|
|
||||||
const actionDropdown = document.getElementById(actionToggleId as string) as HTMLElement;
|
|
||||||
await userEvent.click(within(actionDropdown).getByTestId(`action-${action}`));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const showToast = vi.fn();
|
|
||||||
const showError = vi.fn();
|
|
||||||
vi.mock('@/composables/useToast', () => ({
|
vi.mock('@/composables/useToast', () => ({
|
||||||
useToast: () => ({
|
useToast: vi.fn(() => mockToast),
|
||||||
showToast,
|
|
||||||
showError,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
vi.mock('@/composables/useClipboard', () => ({
|
||||||
const goToUpgrade = vi.fn();
|
useClipboard: vi.fn(() => mockClipboard),
|
||||||
return {
|
}));
|
||||||
usePageRedirectionHelper: () => ({
|
|
||||||
goToUpgrade,
|
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', () => {
|
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(() => {
|
afterEach(() => {
|
||||||
copy.mockReset();
|
cleanupAppModals();
|
||||||
showToast.mockReset();
|
vi.clearAllMocks();
|
||||||
showError.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turn enforcing mfa on', async () => {
|
it('turn enforcing mfa on', async () => {
|
||||||
const pinia = createTestingPinia({
|
const { getByTestId } = renderComponent();
|
||||||
initialState: getInitialState({
|
|
||||||
settings: {
|
|
||||||
settings: {
|
|
||||||
enterprise: {
|
|
||||||
mfaEnforcement: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const userStore = mockedStore(useUsersStore);
|
|
||||||
const { getByTestId } = renderView({ pinia });
|
|
||||||
|
|
||||||
const actionSwitch = getByTestId('enable-force-mfa');
|
const actionSwitch = getByTestId('enable-force-mfa');
|
||||||
expect(actionSwitch).toBeInTheDocument();
|
expect(actionSwitch).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(actionSwitch);
|
await userEvent.click(actionSwitch);
|
||||||
|
|
||||||
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(true);
|
expect(usersStore.updateEnforceMfa).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('turn enforcing mfa off', async () => {
|
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;
|
settingsStore.isMFAEnforced = true;
|
||||||
const { getByTestId } = renderView({ pinia });
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
const actionSwitch = getByTestId('enable-force-mfa');
|
const actionSwitch = getByTestId('enable-force-mfa');
|
||||||
expect(actionSwitch).toBeInTheDocument();
|
expect(actionSwitch).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(actionSwitch);
|
await userEvent.click(actionSwitch);
|
||||||
|
|
||||||
expect(userStore.updateEnforceMfa).toHaveBeenCalledWith(false);
|
expect(usersStore.updateEnforceMfa).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides invite button visibility based on user permissions', async () => {
|
it('should render correctly with users list', () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
renderComponent();
|
||||||
const userStore = mockedStore(useUsersStore);
|
|
||||||
userStore.currentUser = createUser({ isDefaultUser: true });
|
|
||||||
|
|
||||||
const { queryByTestId } = renderView({ pinia });
|
expect(screen.getByRole('heading', { name: /users/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('users-list-search')).toBeInTheDocument();
|
||||||
expect(queryByTestId('settings-users-invite-button')).not.toBeInTheDocument();
|
expect(screen.getByTestId('settings-users-invite-button')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Below quota', () => {
|
it('should open invite modal when invite button is clicked', async () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
renderComponent();
|
||||||
|
|
||||||
const usersStore = mockedStore(useUsersStore);
|
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||||
|
await userEvent.click(inviteButton);
|
||||||
|
|
||||||
|
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||||
|
name: INVITE_USER_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
afterInvite: expect.any(Function),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable invite button when SSO is enabled', () => {
|
||||||
|
ssoStore.isSamlLoginEnabled = true;
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||||
|
expect(inviteButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable invite button when users limit is reached', () => {
|
||||||
usersStore.usersLimitNotReached = false;
|
usersStore.usersLimitNotReached = false;
|
||||||
|
|
||||||
it('disables the invite button', async () => {
|
renderComponent();
|
||||||
const { getByTestId } = renderView({ pinia });
|
|
||||||
|
|
||||||
expect(getByTestId('settings-users-invite-button')).toBeDisabled();
|
const inviteButton = screen.getByTestId('settings-users-invite-button');
|
||||||
|
expect(inviteButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows the user to upgrade', async () => {
|
it('should handle search input with debouncing', async () => {
|
||||||
const { getByTestId } = renderView({ pinia });
|
renderComponent();
|
||||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
|
||||||
|
|
||||||
const actionBox = getByTestId('action-box');
|
const searchInput = screen.getByTestId('users-list-search');
|
||||||
expect(actionBox).toBeInTheDocument();
|
await userEvent.type(searchInput, 'test search');
|
||||||
|
|
||||||
await userEvent.click(await within(actionBox).findByText('View plans'));
|
await waitFor(() => {
|
||||||
|
expect(usersStore.usersList.execute).toHaveBeenCalled();
|
||||||
expect(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith(
|
|
||||||
'settings-users',
|
|
||||||
'upgrade-users',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the invite button on SAML login', async () => {
|
it('should show upgrade banner when users limit is reached', () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
usersStore.usersLimitNotReached = false;
|
||||||
const ssoStore = useSSOStore(pinia);
|
|
||||||
ssoStore.isSamlLoginEnabled = true;
|
|
||||||
|
|
||||||
const { getByTestId } = renderView({ pinia });
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
expect(getByTestId('settings-users-invite-button')).toBeDisabled();
|
expect(getByTestId('action-box')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the invite modal', async () => {
|
it('should show advanced permissions notice when feature is disabled', async () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.AdvancedPermissions] = false;
|
||||||
const { getByTestId } = renderView({ pinia });
|
|
||||||
|
|
||||||
const uiStore = useUIStore();
|
renderComponent();
|
||||||
await userEvent.click(getByTestId('settings-users-invite-button'));
|
|
||||||
|
|
||||||
expect(uiStore.openModal).toHaveBeenCalledWith('inviteUser');
|
expect(screen.getByTestId('upgrade-permissions-link')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByTestId('upgrade-permissions-link'));
|
||||||
|
|
||||||
|
expect(mockPageRedirectionHelper.goToUpgrade).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows warning when advanced permissions are not enabled', async () => {
|
it('should not show users table when limit is reached and only one user exists', () => {
|
||||||
const pinia = createTestingPinia({
|
usersStore.usersLimitNotReached = false;
|
||||||
initialState: getInitialState({
|
usersStore.usersList.state = {
|
||||||
[STORES.SETTINGS]: { settings: { enterprise: { advancedPermissions: false } } },
|
...mockUsersList,
|
||||||
}),
|
count: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(queryByTestId('settings-users-table')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = renderView({ pinia });
|
it('should show users table when limit is reached but multiple users exist', () => {
|
||||||
|
usersStore.usersLimitNotReached = false;
|
||||||
|
usersStore.usersList.state = {
|
||||||
|
...mockUsersList,
|
||||||
|
count: 3,
|
||||||
|
};
|
||||||
|
|
||||||
expect(getByText('to unlock the ability to create additional admin users'));
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
// The users container should be visible when there are multiple users
|
||||||
|
expect(getByTestId('settings-users-table')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('per user actions', () => {
|
describe('user actions', () => {
|
||||||
it('should copy invite link to clipboard', async () => {
|
it('should handle delete user action', async () => {
|
||||||
const action = 'copyInviteLink';
|
renderComponent();
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
emitters.settingsUsersTable.emit('action', { action: 'delete', userId: '2' });
|
||||||
|
|
||||||
const { getByTestId } = renderView({ pinia });
|
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
|
||||||
|
name: DELETE_USER_MODAL_KEY,
|
||||||
await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
|
data: {
|
||||||
|
userId: '2',
|
||||||
expect(copy).toHaveBeenCalledWith(invitedUser.inviteAcceptUrl);
|
afterDelete: expect.any(Function),
|
||||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re invite users', async () => {
|
it('should handle reinvite user action', async () => {
|
||||||
const action = 'reinvite';
|
renderComponent();
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
emitters.settingsUsersTable.emit('action', { action: 'reinvite', userId: '3' });
|
||||||
|
|
||||||
const settingsStore = mockedStore(useSettingsStore);
|
expect(usersStore.reinviteUser).toHaveBeenCalledWith({
|
||||||
settingsStore.isSmtpSetup = true;
|
email: 'pending@example.com',
|
||||||
|
role: ROLE.Member,
|
||||||
const userStore = useUsersStore();
|
});
|
||||||
|
await waitFor(() => {
|
||||||
const { getByTestId } = renderView({ pinia });
|
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
await triggerUserAction(getByTestId(`user-list-item-${invitedUser.email}`), action);
|
title: expect.any(String),
|
||||||
|
message: expect.any(String),
|
||||||
expect(userStore.reinviteUser).toHaveBeenCalled();
|
});
|
||||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show delete users modal with the right permissions', async () => {
|
it('should handle copy invite link action', async () => {
|
||||||
const action = 'delete';
|
renderComponent();
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
emitters.settingsUsersTable.emit('action', { action: 'copyInviteLink', userId: '3' });
|
||||||
|
|
||||||
const rbacStore = mockedStore(useRBACStore);
|
expect(mockClipboard.copy).toHaveBeenCalledWith('https://example.com/invite/123');
|
||||||
rbacStore.hasScope.mockReturnValue(true);
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||||
const { getByTestId } = renderView({ pinia });
|
type: 'success',
|
||||||
|
title: expect.any(String),
|
||||||
await triggerUserAction(getByTestId(`user-list-item-${user.email}`), action);
|
message: expect.any(String),
|
||||||
|
});
|
||||||
const uiStore = useUIStore();
|
});
|
||||||
expect(uiStore.openDeleteUserModal).toHaveBeenCalledWith(user.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow coping reset password link', async () => {
|
it('should handle copy password reset link action', async () => {
|
||||||
const action = 'copyPasswordResetLink';
|
renderComponent();
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
emitters.settingsUsersTable.emit('action', { action: 'copyPasswordResetLink', userId: '2' });
|
||||||
|
|
||||||
const rbacStore = mockedStore(useRBACStore);
|
expect(usersStore.getUserPasswordResetLink).toHaveBeenCalledWith(mockUsersList.items[1]);
|
||||||
rbacStore.hasScope.mockReturnValue(true);
|
await waitFor(() => {
|
||||||
|
expect(mockClipboard.copy).toHaveBeenCalledWith('https://example.com/reset/123');
|
||||||
const userStore = mockedStore(useUsersStore);
|
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||||
userStore.getUserPasswordResetLink.mockResolvedValue({ link: 'dummy-reset-password' });
|
type: 'success',
|
||||||
|
title: expect.any(String),
|
||||||
const { getByTestId } = renderView({ pinia });
|
message: expect.any(String),
|
||||||
|
});
|
||||||
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' }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enable SSO manual login', async () => {
|
it('should handle allow SSO manual login action', async () => {
|
||||||
const action = 'allowSSOManualLogin';
|
renderComponent();
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
emitters.settingsUsersTable.emit('action', { action: 'allowSSOManualLogin', userId: '2' });
|
||||||
|
|
||||||
const ssoStore = useSSOStore(pinia);
|
expect(usersStore.updateOtherUserSettings).toHaveBeenCalledWith('2', {
|
||||||
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, {
|
|
||||||
allowSSOManualLogin: true,
|
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 () => {
|
it('should handle disallow SSO manual login action', async () => {
|
||||||
const action = 'disallowSSOManualLogin';
|
// 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);
|
emitters.settingsUsersTable.emit('action', { action: 'disallowSSOManualLogin', userId: '2' });
|
||||||
ssoStore.isSamlLoginEnabled = true;
|
|
||||||
|
|
||||||
const userStore = useUsersStore();
|
expect(usersStore.updateOtherUserSettings).toHaveBeenCalledWith('2', {
|
||||||
|
|
||||||
const { getByTestId } = renderView({ pinia });
|
|
||||||
|
|
||||||
await triggerUserAction(getByTestId(`user-list-item-${userWithDisabledSSO.email}`), action);
|
|
||||||
|
|
||||||
expect(userStore.updateOtherUserSettings).toHaveBeenCalledWith(userWithDisabledSSO.id, {
|
|
||||||
allowSSOManualLogin: false,
|
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 show success toast when changing a user's role", async () => {
|
it('should handle reinvite user error', async () => {
|
||||||
const pinia = createTestingPinia({ initialState: getInitialState() });
|
usersStore.reinviteUser = vi.fn().mockRejectedValue(new Error('Failed to reinvite'));
|
||||||
|
|
||||||
const rbacStore = mockedStore(useRBACStore);
|
renderComponent();
|
||||||
rbacStore.hasScope.mockReturnValue(true);
|
|
||||||
|
|
||||||
const userStore = useUsersStore();
|
emitters.settingsUsersTable.emit('action', { action: 'reinvite', userId: '3' });
|
||||||
|
|
||||||
const { getByTestId } = renderView({ pinia });
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||||
const userListItem = getByTestId(`user-list-item-${invitedUser.email}`);
|
});
|
||||||
expect(userListItem).toBeInTheDocument();
|
});
|
||||||
|
});
|
||||||
const roleSelect = within(userListItem).getByTestId('user-role-select');
|
|
||||||
|
describe('role updates', () => {
|
||||||
const roleDropdownItems = await getDropdownItems(roleSelect);
|
it('should handle role update', async () => {
|
||||||
await userEvent.click(roleDropdownItems[0]);
|
renderComponent();
|
||||||
|
|
||||||
expect(userStore.updateGlobalRole).toHaveBeenCalledWith(
|
emitters.settingsUsersTable.emit('update:role', { role: ROLE.Admin, userId: '2' });
|
||||||
expect.objectContaining({ newRoleName: 'global:member' }),
|
|
||||||
);
|
expect(usersStore.updateGlobalRole).toHaveBeenCalledWith({
|
||||||
|
id: '2',
|
||||||
expect(showToast).toHaveBeenCalledWith(
|
newRoleName: ROLE.Admin,
|
||||||
expect.objectContaining({ type: 'success', message: expect.stringContaining('Member') }),
|
});
|
||||||
);
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showToast).toHaveBeenCalledWith({
|
||||||
|
type: 'success',
|
||||||
|
title: expect.any(String),
|
||||||
|
message: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle role update error', async () => {
|
||||||
|
usersStore.updateGlobalRole = vi.fn().mockRejectedValue(new Error('Failed to update role'));
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
emitters.settingsUsersTable.emit('update:role', { role: ROLE.Admin, userId: '2' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('table functionality', () => {
|
||||||
|
it('should handle table options update', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
emitters.settingsUsersTable.emit('update:options', {
|
||||||
|
page: 1,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
sortBy: [{ id: 'role', desc: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
<script lang="ts" setup>
|
||||||
import { ROLE, type Role } from '@n8n/api-types';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY } from '@/constants';
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
import type { InvitableRoleName, IUser } from '@/Interface';
|
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 { 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 { useToast } from '@/composables/useToast';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@@ -10,11 +23,10 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { useSSOStore } from '@/stores/sso.store';
|
import { useSSOStore } from '@/stores/sso.store';
|
||||||
import { hasPermission } from '@/utils/rbac/permissions';
|
import { hasPermission } from '@/utils/rbac/permissions';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import type { UpdateGlobalRolePayload } from '@/api/users';
|
|
||||||
import { computed, onMounted } from 'vue';
|
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import SettingsUsersTable from '@/components/SettingsUsers/SettingsUsersTable.vue';
|
||||||
|
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const { showToast, showError } = useToast();
|
const { showToast, showError } = useToast();
|
||||||
@@ -30,17 +42,23 @@ const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
|
|||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const showUMSetupWarning = computed(() => {
|
const search = ref('');
|
||||||
return hasPermission(['defaultUser']);
|
const usersTableState = ref<TableOptions>({
|
||||||
|
page: 0,
|
||||||
|
itemsPerPage: 10,
|
||||||
|
sortBy: [
|
||||||
|
{ id: 'firstName', desc: false },
|
||||||
|
{ id: 'lastName', desc: false },
|
||||||
|
{ id: 'email', desc: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
const showUMSetupWarning = computed(() => hasPermission(['defaultUser']));
|
||||||
const allUsers = computed(() => usersStore.allUsers);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
documentTitle.set(i18n.baseText('settings.users'));
|
documentTitle.set(i18n.baseText('settings.users'));
|
||||||
|
|
||||||
if (!showUMSetupWarning.value) {
|
if (!showUMSetupWarning.value) {
|
||||||
await usersStore.fetchUsers();
|
await updateUsersTableData(usersTableState.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,13 +75,6 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
|
|||||||
guard: (user) =>
|
guard: (user) =>
|
||||||
usersStore.usersLimitNotReached && !user.firstName && settingsStore.isSmtpSetup,
|
usersStore.usersLimitNotReached && !user.firstName && settingsStore.isSmtpSetup,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: i18n.baseText('settings.users.actions.delete'),
|
|
||||||
value: 'delete',
|
|
||||||
guard: (user) =>
|
|
||||||
hasPermission(['rbac'], { rbac: { scope: 'user:delete' } }) &&
|
|
||||||
user.id !== usersStore.currentUserId,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: i18n.baseText('settings.users.actions.copyPasswordResetLink'),
|
label: i18n.baseText('settings.users.actions.copyPasswordResetLink'),
|
||||||
value: 'copyPasswordResetLink',
|
value: 'copyPasswordResetLink',
|
||||||
@@ -85,9 +96,9 @@ const usersListActions = computed((): Array<UserAction<IUser>> => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const isAdvancedPermissionsEnabled = computed((): boolean => {
|
const isAdvancedPermissionsEnabled = computed(
|
||||||
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions],
|
||||||
});
|
);
|
||||||
|
|
||||||
const userRoles = computed((): Array<{ value: Role; label: string; disabled?: boolean }> => {
|
const userRoles = computed((): Array<{ value: Role; label: string; disabled?: boolean }> => {
|
||||||
return [
|
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 }) {
|
async function onUsersListAction({ action, userId }: { action: string; userId: string }) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'delete':
|
case 'delete':
|
||||||
@@ -130,16 +137,28 @@ async function onUsersListAction({ action, userId }: { action: string; userId: s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onInvite() {
|
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) {
|
async function onDelete(userId: string) {
|
||||||
const user = usersStore.usersById[userId];
|
uiStore.openModalWithData({
|
||||||
if (user) {
|
name: DELETE_USER_MODAL_KEY,
|
||||||
uiStore.openDeleteUserModal(userId);
|
data: {
|
||||||
}
|
userId,
|
||||||
|
afterDelete: async () => {
|
||||||
|
await updateUsersTableData(usersTableState.value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function onReinvite(userId: string) {
|
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 (user?.email && user?.role) {
|
||||||
if (!['global:admin', 'global:member'].includes(user.role)) {
|
if (!['global:admin', 'global:member'].includes(user.role)) {
|
||||||
throw new Error('Invalid role name on reinvite');
|
throw new Error('Invalid role name on reinvite');
|
||||||
@@ -162,7 +181,7 @@ async function onReinvite(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onCopyInviteLink(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) {
|
if (user?.inviteAcceptUrl) {
|
||||||
void clipboard.copy(user.inviteAcceptUrl);
|
void clipboard.copy(user.inviteAcceptUrl);
|
||||||
|
|
||||||
@@ -174,7 +193,7 @@ async function onCopyInviteLink(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onCopyPasswordResetLink(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) {
|
if (user) {
|
||||||
const url = await usersStore.getUserPasswordResetLink(user);
|
const url = await usersStore.getUserPasswordResetLink(user);
|
||||||
void clipboard.copy(url.link);
|
void clipboard.copy(url.link);
|
||||||
@@ -187,13 +206,14 @@ async function onCopyPasswordResetLink(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onAllowSSOManualLogin(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) {
|
||||||
if (!user.settings) {
|
if (!user.settings) {
|
||||||
user.settings = {};
|
user.settings = {};
|
||||||
}
|
}
|
||||||
user.settings.allowSSOManualLogin = true;
|
user.settings.allowSSOManualLogin = true;
|
||||||
await usersStore.updateOtherUserSettings(userId, user.settings);
|
await usersStore.updateOtherUserSettings(userId, user.settings);
|
||||||
|
await updateUsersTableData(usersTableState.value);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@@ -203,10 +223,12 @@ async function onAllowSSOManualLogin(userId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function onDisallowSSOManualLogin(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) {
|
if (user?.settings) {
|
||||||
user.settings.allowSSOManualLogin = false;
|
user.settings.allowSSOManualLogin = false;
|
||||||
await usersStore.updateOtherUserSettings(userId, user.settings);
|
await usersStore.updateOtherUserSettings(userId, user.settings);
|
||||||
|
await updateUsersTableData(usersTableState.value);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: i18n.baseText('settings.users.disallowSSOManualLogin'),
|
title: i18n.baseText('settings.users.disallowSSOManualLogin'),
|
||||||
@@ -220,7 +242,18 @@ function goToUpgrade() {
|
|||||||
function goToUpgradeAdvancedPermissions() {
|
function goToUpgradeAdvancedPermissions() {
|
||||||
void pageRedirectionHelper.goToUpgrade('settings-users', 'upgrade-advanced-permissions');
|
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 {
|
try {
|
||||||
await usersStore.updateGlobalRole({ id: user.id, newRoleName });
|
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'),
|
title: i18n.baseText('settings.users.userRoleUpdated'),
|
||||||
message: i18n.baseText('settings.users.userRoleUpdated.message', {
|
message: i18n.baseText('settings.users.userRoleUpdated.message', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
user: user.fullName ?? '',
|
user:
|
||||||
|
user.firstName && user.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: (user.email ?? ''),
|
||||||
role,
|
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) {
|
async function onUpdateMfaEnforced(value: boolean) {
|
||||||
try {
|
try {
|
||||||
await usersStore.updateEnforceMfa(value);
|
await usersStore.updateEnforceMfa(value);
|
||||||
@@ -261,25 +340,9 @@ async function onUpdateMfaEnforced(value: boolean) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<div>
|
<n8n-heading tag="h1" size="2xlarge" class="mb-xl">
|
||||||
<n8n-heading size="2xlarge">{{ i18n.baseText('settings.users') }}</n8n-heading>
|
{{ i18n.baseText('settings.users') }}
|
||||||
<div v-if="!showUMSetupWarning" :class="$style.buttonContainer">
|
</n8n-heading>
|
||||||
<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>
|
|
||||||
<div v-if="!usersStore.usersLimitNotReached" :class="$style.setupInfoContainer">
|
<div v-if="!usersStore.usersLimitNotReached" :class="$style.setupInfoContainer">
|
||||||
<n8n-action-box
|
<n8n-action-box
|
||||||
:heading="
|
:heading="
|
||||||
@@ -297,7 +360,11 @@ async function onUpdateMfaEnforced(value: boolean) {
|
|||||||
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
<n8n-notice v-if="!isAdvancedPermissionsEnabled">
|
||||||
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||||
<template #link>
|
<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') }}
|
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -340,76 +407,74 @@ async function onUpdateMfaEnforced(value: boolean) {
|
|||||||
</EnterpriseEdition>
|
</EnterpriseEdition>
|
||||||
</div>
|
</div>
|
||||||
</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.
|
<!-- 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
|
<div
|
||||||
v-if="usersStore.usersLimitNotReached || allUsers.length > 1"
|
v-if="usersStore.usersLimitNotReached || usersStore.usersList.state.count > 1"
|
||||||
:class="$style.usersContainer"
|
:class="$style.usersContainer"
|
||||||
>
|
>
|
||||||
<n8n-users-list
|
<SettingsUsersTable
|
||||||
|
data-test-id="settings-users-table"
|
||||||
|
:data="usersStore.usersList.state"
|
||||||
|
:loading="usersStore.usersList.isLoading"
|
||||||
:actions="usersListActions"
|
:actions="usersListActions"
|
||||||
:users="allUsers"
|
@update:options="updateUsersTableData"
|
||||||
:current-user-id="usersStore.currentUserId"
|
@update:role="onUpdateRole"
|
||||||
:is-saml-login-enabled="ssoStore.isSamlLoginEnabled"
|
|
||||||
@action="onUsersListAction"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
|
||||||
height: 100%;
|
|
||||||
padding-right: var(--spacing-2xs);
|
|
||||||
|
|
||||||
> * {
|
|
||||||
margin-bottom: var(--spacing-2xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.usersContainer {
|
|
||||||
> * {
|
|
||||||
margin-bottom: var(--spacing-2xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContainer {
|
.buttonContainer {
|
||||||
display: inline-block;
|
display: flex;
|
||||||
float: right;
|
justify-content: space-between;
|
||||||
margin-bottom: var(--spacing-l);
|
gap: var(--spacing-s);
|
||||||
|
margin: 0 0 var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setupInfoContainer {
|
.setupInfoContainer {
|
||||||
max-width: 728px;
|
max-width: 728px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
|
||||||
left: calc(50% + 100px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settingsContainer {
|
.settingsContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-left: 16px;
|
padding-left: var(--spacing-s);
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ onMounted(() => {
|
|||||||
.content {
|
.content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 1440px;
|
||||||
padding: 0 var(--spacing-2xl);
|
padding: 0 var(--spacing-2xl);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user