refactor: Add common DTOs and schemas for the redesigned users list (#16097)

This commit is contained in:
Csaba Tuncsik
2025-06-11 14:55:04 +02:00
committed by GitHub
parent b9e03515bd
commit e681e6b477
23 changed files with 348 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = `
<body style="margin-top: 20px; font-family: 'Open Sans', sans-serif; text-align: center;">
<h1 style="font-size: 40px">&#x1F6AB;</h1>

View File

@@ -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<IRole[]>([]);
const globalRoles = ref<Role[]>([]);
const rolesByProjectId = ref<Record<string, string[]>>({});
const globalScopes = ref<Scope[]>([]);
@@ -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);
}

View File

@@ -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<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let cloudPlanStore: ReturnType<typeof useCloudPlanStore>;
function setUser(role: IRole) {
function setUser(role: Role) {
useUsersStore().addUsers([
{
id: '1',

View File

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

View File

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

View File

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

View File

@@ -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<RolePermissionOptions> = (checkRoles) => {
const usersStore = useUsersStore();
@@ -9,7 +8,7 @@ export const hasRole: RBACPermissionCheck<RolePermissionOptions> = (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;

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ROLE, type Role } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import type { IFormInputs, IRole, IUser, ThemeOption } from '@/Interface';
import type { IFormInputs, IUser, ThemeOption } from '@/Interface';
import {
CHANGE_PASSWORD_MODAL_KEY,
MFA_DOCS_URL,
MFA_SETUP_MODAL_KEY,
PROMPT_MFA_CODE_MODAL_KEY,
ROLE,
} from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
@@ -90,7 +90,7 @@ const hasAnyChanges = computed(() => {
return hasAnyBasicInfoChanges.value || hasAnyPersonalisationChanges.value;
});
const roles = computed<Record<IRole, RoleContent>>(() => ({
const roles = computed<Record<Role, RoleContent>>(() => ({
[ROLE.Default]: {
name: i18n.baseText('auth.roles.default'),
description: i18n.baseText('settings.personal.role.tooltip.default'),

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, ROLE } from '@/constants';
import type { IRole, IUser, IUserListAction, InvitableRoleName } from '@/Interface';
import { ROLE, type Role } from '@n8n/api-types';
import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY } from '@/constants';
import type { IUser, IUserListAction, InvitableRoleName } from '@/Interface';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -85,7 +85,7 @@ const isAdvancedPermissionsEnabled = computed((): boolean => {
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
});
const userRoles = computed((): Array<{ value: IRole; label: string; disabled?: boolean }> => {
const userRoles = computed((): Array<{ value: Role; label: string; disabled?: boolean }> => {
return [
{
value: ROLE.Member,