diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 277d3b9c0a..5808ab88ae 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -71,3 +71,4 @@ export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto export { InsightsDateFilterDto } from './insights/date-filter.dto'; export { PaginationDto } from './pagination/pagination.dto'; +export { UsersListFilterDto } from './user/users-list-filter.dto'; diff --git a/packages/@n8n/api-types/src/dto/user/__tests__/users-list-filter.dto.test.ts b/packages/@n8n/api-types/src/dto/user/__tests__/users-list-filter.dto.test.ts new file mode 100644 index 0000000000..368900fe9c --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/__tests__/users-list-filter.dto.test.ts @@ -0,0 +1,73 @@ +import { UsersListFilterDto } from '../users-list-filter.dto'; + +describe('UsersListFilterDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'pagination with default values', + request: {}, + parsedResult: { skip: 0, take: 10 }, + }, + { + name: 'pagination with custom values', + request: { skip: '5', take: '20' }, + parsedResult: { skip: 5, take: 20 }, + }, + { + name: 'sort by name ascending', + request: { sortBy: 'name:asc' }, + parsedResult: { skip: 0, take: 10, sortBy: 'name:asc' }, + }, + { + name: 'sort by last active descending and pagination', + request: { skip: '5', take: '20', sortBy: 'lastActive:desc' }, + parsedResult: { skip: 5, take: 20, sortBy: 'lastActive:desc' }, + }, + ])('should validate $name', ({ request, parsedResult }) => { + const result = UsersListFilterDto.safeParse(request); + expect(result.success).toBe(true); + if (parsedResult) { + expect(result.data).toMatchObject(parsedResult); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid skip format', + request: { + skip: 'not-a-number', + take: '10', + }, + expectedErrorPath: ['skip'], + }, + { + name: 'invalid take format', + request: { + skip: '0', + take: 'not-a-number', + }, + expectedErrorPath: ['take'], + }, + { + name: 'invalid sortBy value', + request: { + sortBy: 'invalid-value', + }, + expectedErrorPath: ['sortBy'], + }, + ])('should invalidate $name', ({ request, expectedErrorPath }) => { + const result = UsersListFilterDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + if (Array.isArray(expectedErrorPath)) { + const errorPaths = result.error.issues[0].path; + expect(errorPaths).toContain(expectedErrorPath[0]); + } + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts b/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts new file mode 100644 index 0000000000..fd1ed35051 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/user/users-list-filter.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +import { paginationSchema } from '../pagination/pagination.dto'; + +const USERS_LIST_SORT_OPTIONS = [ + 'name:asc', + 'name:desc', + 'role:asc', // ascending order by role is Owner, Admin, Member + 'role:desc', + 'lastActive:asc', + 'lastActive:desc', +] as const; + +const usersListSortByValidator = z + .enum(USERS_LIST_SORT_OPTIONS, { + message: `sortBy must be one of: ${USERS_LIST_SORT_OPTIONS.join(', ')}`, + }) + .optional(); + +export class UsersListFilterDto extends Z.class({ + ...paginationSchema, + // Default sort order is role:asc, secondary sort criteria is name:asc + sortBy: usersListSortByValidator, +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index e30032e081..59f4dc9501 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -38,3 +38,9 @@ export { type InsightsDateRange, INSIGHTS_DATE_RANGE_KEYS, } from './schemas/insights.schema'; + +export { + ROLE, + type Role, + type UsersList, +} from './schemas/user.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts new file mode 100644 index 0000000000..744c6b59c4 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/user.schema.test.ts @@ -0,0 +1,170 @@ +import { roleSchema, userListItemSchema, usersListSchema } from '../user.schema'; + +describe('user.schema', () => { + describe('roleSchema', () => { + test.each([ + ['global:owner', true], + ['global:member', true], + ['global:admin', true], + ['default', true], + ['invalid-role', false], + ])('should validate role %s', (value, expected) => { + const result = roleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + + describe('userListItemSchema', () => { + test.each([ + { + name: 'valid user', + data: { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + role: 'global:member', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: ['project1', 'project2'], + }, + isValid: true, + }, + { + name: 'user with null fields', + data: { + id: '123', + firstName: null, + lastName: null, + email: null, + role: 'global:member', + isPending: false, + lastActive: null, + projects: null, + }, + isValid: true, + }, + { + name: 'invalid email', + data: { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'not-an-email', + role: 'global:member', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: ['project1', 'project2'], + }, + isValid: false, + }, + { + name: 'missing required fields', + data: { + firstName: 'John', + lastName: 'Doe', + email: null, + role: 'global:member', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + }, + isValid: false, + }, + { + name: 'invalid role', + data: { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + role: 'invalid-role', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: ['project1', 'project2'], + }, + isValid: false, + }, + ])('should validate $name', ({ data, isValid }) => { + const result = userListItemSchema.safeParse(data); + expect(result.success).toBe(isValid); + }); + }); + + describe('usersListSchema', () => { + test.each([ + { + name: 'valid users list', + data: { + count: 2, + data: [ + { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + role: 'global:member', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: ['project1', 'project2'], + }, + { + id: '456', + firstName: 'Jane', + lastName: 'Doe', + email: 'janedoe@example.com', + role: 'global:admin', + isPending: true, + lastActive: '2023-10-02T12:00:00Z', + projects: null, + }, + ], + }, + isValid: true, + }, + { + name: 'empty users list', + data: { + count: 0, + data: [], + }, + isValid: true, + }, + { + name: 'missing count', + data: { + data: [], + }, + isValid: false, + }, + { + name: 'missing data', + data: { + count: 5, + }, + isValid: false, + }, + { + name: 'invalid user in list', + data: { + count: 1, + data: [ + { + id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'email', + role: 'global:member', + isPending: false, + lastActive: '2023-10-01T12:00:00Z', + projects: ['project1', 'project2'], + }, + ], + }, + isValid: false, + }, + ])('should validate $name', ({ data, isValid }) => { + const result = usersListSchema.safeParse(data); + expect(result.success).toBe(isValid); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/user.schema.ts b/packages/@n8n/api-types/src/schemas/user.schema.ts new file mode 100644 index 0000000000..5bd2301b82 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/user.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const ROLE = { + Owner: 'global:owner', + Member: 'global:member', + Admin: 'global:admin', + Default: 'default', // default user with no email when setting up instance +} as const; + +export type Role = (typeof ROLE)[keyof typeof ROLE]; + +// Ensuring the array passed to z.enum is correctly typed as non-empty. +const roleValuesForSchema = Object.values(ROLE) as [Role, ...Role[]]; +export const roleSchema = z.enum(roleValuesForSchema); + +export const userListItemSchema = z.object({ + id: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + email: z.string().email().nullable(), + role: roleSchema, + isPending: z.boolean(), + lastActive: z.string().nullable(), + projects: z.array(z.string()).nullable(), // Can be null if the user is the owner or is an admin +}); + +export const usersListSchema = z.object({ + count: z.number(), + data: z.array(userListItemSchema), +}); + +export type UsersList = z.infer; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index fb6626c9e5..e7f5a693de 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -6,6 +6,8 @@ import type { Iso8601DateTimeString, IUserManagementSettings, IVersionNotificationSettings, + ROLE, + Role, } from '@n8n/api-types'; import type { Scope } from '@n8n/permissions'; import type { NodeCreatorTag } from '@n8n/design-system'; @@ -55,7 +57,6 @@ import type { TRIGGER_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW, AI_OTHERS_NODE_CREATOR_VIEW, - ROLE, AI_UNCATEGORIZED_CATEGORY, AI_EVALUATION, } from '@/constants'; @@ -568,9 +569,7 @@ export type IPersonalizationSurveyVersions = | IPersonalizationSurveyAnswersV3 | IPersonalizationSurveyAnswersV4; -export type Roles = typeof ROLE; -export type IRole = Roles[keyof Roles]; -export type InvitableRoleName = Roles['Member' | 'Admin']; +export type InvitableRoleName = (typeof ROLE)['Member' | 'Admin']; export interface IUserResponse { id: string; @@ -578,7 +577,7 @@ export interface IUserResponse { lastName?: string; email?: string; createdAt?: string; - role?: IRole; + role?: Role; globalScopes?: Scope[]; personalizationAnswers?: IPersonalizationSurveyVersions | null; isPending: boolean; @@ -613,7 +612,7 @@ export const enum UserManagementAuthenticationMethod { export interface IPermissionGroup { loginStatus?: ILogInStatus[]; - role?: IRole[]; + role?: Role[]; } export interface IPermissionAllowGroup extends IPermissionGroup { @@ -1168,7 +1167,7 @@ export interface IInviteResponse { email: string; emailSent: boolean; inviteAcceptUrl: string; - role: IRole; + role: Role; }; error?: string; } diff --git a/packages/frontend/editor-ui/src/components/BannersStack.test.ts b/packages/frontend/editor-ui/src/components/BannersStack.test.ts index 26744843f4..a5835bb5dc 100644 --- a/packages/frontend/editor-ui/src/components/BannersStack.test.ts +++ b/packages/frontend/editor-ui/src/components/BannersStack.test.ts @@ -2,7 +2,7 @@ import merge from 'lodash/merge'; import userEvent from '@testing-library/user-event'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; -import { ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import { STORES } from '@n8n/stores'; import { createTestingPinia } from '@pinia/testing'; diff --git a/packages/frontend/editor-ui/src/components/InviteUsersModal.vue b/packages/frontend/editor-ui/src/components/InviteUsersModal.vue index 4e6c17374b..def1f2ea26 100644 --- a/packages/frontend/editor-ui/src/components/InviteUsersModal.vue +++ b/packages/frontend/editor-ui/src/components/InviteUsersModal.vue @@ -3,12 +3,8 @@ import { computed, onMounted, ref } from 'vue'; import { useToast } from '@/composables/useToast'; import Modal from './Modal.vue'; import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface'; -import { - EnterpriseEditionFeature, - VALID_EMAIL_REGEX, - INVITE_USER_MODAL_KEY, - ROLE, -} from '@/constants'; +import { EnterpriseEditionFeature, VALID_EMAIL_REGEX, INVITE_USER_MODAL_KEY } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import { useUsersStore } from '@/stores/users.store'; import { useSettingsStore } from '@/stores/settings.store'; import { createFormEventBus } from '@n8n/design-system/utils'; diff --git a/packages/frontend/editor-ui/src/components/banners/V1Banner.test.ts b/packages/frontend/editor-ui/src/components/banners/V1Banner.test.ts index 9636bbaae6..8332e8d215 100644 --- a/packages/frontend/editor-ui/src/components/banners/V1Banner.test.ts +++ b/packages/frontend/editor-ui/src/components/banners/V1Banner.test.ts @@ -2,7 +2,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import V1Banner from './V1Banner.vue'; import { createPinia, setActivePinia } from 'pinia'; import { useUsersStore } from '@/stores/users.store'; -import { ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import type { IUser } from '@/Interface'; const renderComponent = createComponentRenderer(V1Banner); diff --git a/packages/frontend/editor-ui/src/composables/usePageRedirectionHelper.test.ts b/packages/frontend/editor-ui/src/composables/usePageRedirectionHelper.test.ts index 76b04accc9..2a6dd6e6e6 100644 --- a/packages/frontend/editor-ui/src/composables/usePageRedirectionHelper.test.ts +++ b/packages/frontend/editor-ui/src/composables/usePageRedirectionHelper.test.ts @@ -1,4 +1,4 @@ -import { ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import { useSettingsStore } from '@/stores/settings.store'; import merge from 'lodash/merge'; import { usePageRedirectionHelper } from './usePageRedirectionHelper'; diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index adc5da37e1..dde5f5f12d 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -879,13 +879,6 @@ export const TEMPLATES_URLS = { }, }; -export const ROLE = { - Owner: 'global:owner', - Member: 'global:member', - Admin: 'global:admin', - Default: 'default', // default user with no email when setting up instance -} as const; - export const INSECURE_CONNECTION_WARNING = `

🚫

diff --git a/packages/frontend/editor-ui/src/stores/rbac.store.ts b/packages/frontend/editor-ui/src/stores/rbac.store.ts index 416ae6eefa..6b3f74a85a 100644 --- a/packages/frontend/editor-ui/src/stores/rbac.store.ts +++ b/packages/frontend/editor-ui/src/stores/rbac.store.ts @@ -3,10 +3,10 @@ import { hasScope as genericHasScope } from '@n8n/permissions'; import type { ScopeOptions, Scope, Resource } from '@n8n/permissions'; import { ref } from 'vue'; import { STORES } from '@n8n/stores'; -import type { IRole } from '@/Interface'; +import type { Role } from '@n8n/api-types'; export const useRBACStore = defineStore(STORES.RBAC, () => { - const globalRoles = ref([]); + const globalRoles = ref([]); const rolesByProjectId = ref>({}); const globalScopes = ref([]); @@ -38,13 +38,13 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { insights: {}, }); - function addGlobalRole(role: IRole) { + function addGlobalRole(role: Role) { if (!globalRoles.value.includes(role)) { globalRoles.value.push(role); } } - function hasRole(role: IRole) { + function hasRole(role: Role) { return globalRoles.value.includes(role); } diff --git a/packages/frontend/editor-ui/src/stores/ui.test.ts b/packages/frontend/editor-ui/src/stores/ui.test.ts index e8b9878de3..c69ade230f 100644 --- a/packages/frontend/editor-ui/src/stores/ui.test.ts +++ b/packages/frontend/editor-ui/src/stores/ui.test.ts @@ -12,14 +12,13 @@ import { getUserCloudInfo, getNotTrialingUserResponse, } from './__tests__/utils/cloudStoreUtils'; -import type { IRole } from '@/Interface'; -import { ROLE } from '@/constants'; +import { ROLE, type Role } from '@n8n/api-types'; let uiStore: ReturnType; let settingsStore: ReturnType; let cloudPlanStore: ReturnType; -function setUser(role: IRole) { +function setUser(role: Role) { useUsersStore().addUsers([ { id: '1', diff --git a/packages/frontend/editor-ui/src/stores/users.store.ts b/packages/frontend/editor-ui/src/stores/users.store.ts index 168b55a30d..279932baba 100644 --- a/packages/frontend/editor-ui/src/stores/users.store.ts +++ b/packages/frontend/editor-ui/src/stores/users.store.ts @@ -1,13 +1,14 @@ -import type { - LoginRequestDto, - PasswordUpdateRequestDto, - SettingsUpdateRequestDto, - UserUpdateRequestDto, +import { + type LoginRequestDto, + type PasswordUpdateRequestDto, + type SettingsUpdateRequestDto, + type UserUpdateRequestDto, + ROLE, } from '@n8n/api-types'; import type { UpdateGlobalRolePayload } from '@/api/users'; import * as usersApi from '@/api/users'; import { BROWSER_ID_STORAGE_KEY } from '@n8n/constants'; -import { PERSONALIZATION_MODAL_KEY, ROLE } from '@/constants'; +import { PERSONALIZATION_MODAL_KEY } from '@/constants'; import { STORES } from '@n8n/stores'; import type { IPersonalizationLatestVersion, diff --git a/packages/frontend/editor-ui/src/types/rbac.ts b/packages/frontend/editor-ui/src/types/rbac.ts index 47fc0d5664..92ea274284 100644 --- a/packages/frontend/editor-ui/src/types/rbac.ts +++ b/packages/frontend/editor-ui/src/types/rbac.ts @@ -1,5 +1,6 @@ import type { Resource, ScopeOptions, Scope } from '@n8n/permissions'; -import type { EnterpriseEditionFeatureValue, IRole } from '@/Interface'; +import type { EnterpriseEditionFeatureValue } from '@/Interface'; +import type { Role } from '@n8n/api-types'; export type AuthenticatedPermissionOptions = { bypass?: () => boolean; @@ -19,7 +20,7 @@ export type RBACPermissionOptions = { resourceId?: string; options?: ScopeOptions; }; -export type RolePermissionOptions = IRole[]; +export type RolePermissionOptions = Role[]; export type PermissionType = | 'authenticated' diff --git a/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.test.ts b/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.test.ts index 2a3d4abc10..8d0352506f 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.test.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.test.ts @@ -1,6 +1,6 @@ import { useUsersStore } from '@/stores/users.store'; import { hasRole } from '@/utils/rbac/checks'; -import { ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; vi.mock('@/stores/users.store', () => ({ useUsersStore: vi.fn(), diff --git a/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.ts b/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.ts index f9590026e7..d92b776343 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/checks/hasRole.ts @@ -1,7 +1,6 @@ import { useUsersStore } from '@/stores/users.store'; import type { RBACPermissionCheck, RolePermissionOptions } from '@/types/rbac'; -import { ROLE } from '@/constants'; -import type { IRole } from '@/Interface'; +import { ROLE, type Role } from '@n8n/api-types'; export const hasRole: RBACPermissionCheck = (checkRoles) => { const usersStore = useUsersStore(); @@ -9,7 +8,7 @@ export const hasRole: RBACPermissionCheck = (checkRoles) if (currentUser && checkRoles) { const userRole = currentUser.isDefaultUser ? ROLE.Default : currentUser.role; - return checkRoles.includes(userRole as IRole); + return checkRoles.includes(userRole as Role); } return false; diff --git a/packages/frontend/editor-ui/src/utils/rbac/middleware/role.test.ts b/packages/frontend/editor-ui/src/utils/rbac/middleware/role.test.ts index 4a3c3a7e59..f158f63ca5 100644 --- a/packages/frontend/editor-ui/src/utils/rbac/middleware/role.test.ts +++ b/packages/frontend/editor-ui/src/utils/rbac/middleware/role.test.ts @@ -2,7 +2,8 @@ import { roleMiddleware } from '@/utils/rbac/middleware/role'; import { useUsersStore } from '@/stores/users.store'; import type { IUser } from '@/Interface'; import type { RouteLocationNormalized } from 'vue-router'; -import { VIEWS, ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; +import { VIEWS } from '@/constants'; vi.mock('@/stores/users.store', () => ({ useUsersStore: vi.fn(), diff --git a/packages/frontend/editor-ui/src/utils/userUtils.ts b/packages/frontend/editor-ui/src/utils/userUtils.ts index ff862ecfd8..8c0e7802e9 100644 --- a/packages/frontend/editor-ui/src/utils/userUtils.ts +++ b/packages/frontend/editor-ui/src/utils/userUtils.ts @@ -59,8 +59,8 @@ import { BAMBOO_HR_NODE_TYPE, GOOGLE_SHEETS_NODE_TYPE, CODE_NODE_TYPE, - ROLE, } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import type { IPersonalizationSurveyAnswersV1, IPersonalizationSurveyAnswersV2, diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts b/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts index a75ba342df..a3f9fc6227 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts @@ -6,7 +6,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useUsersStore } from '@/stores/users.store'; import { createComponentRenderer } from '@/__tests__/render'; import { setupServer } from '@/__tests__/server'; -import { ROLE } from '@/constants'; +import { ROLE } from '@n8n/api-types'; import { useUIStore } from '@/stores/ui.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue index aae879be08..4fe8f14dfc 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue @@ -1,15 +1,15 @@