feat(core): Extend user list to allow expanding the user list to projects (#16314)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Andreas Fitzek
2025-06-18 22:26:50 +02:00
committed by GitHub
parent 701c31cfbc
commit c0d1ff6e4c
20 changed files with 780 additions and 180 deletions

View File

@@ -50,7 +50,7 @@ describe('Editor zoom should work after route changes', () => {
it('after switching between Editor and Workflow history and Workflow list', () => { it('after switching between Editor and Workflow history and Workflow list', () => {
cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion'); cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion');
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory'); cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
cy.intercept('GET', '/rest/users').as('getUsers'); cy.intercept('GET', '/rest/users?*').as('getUsers');
cy.intercept('GET', '/rest/workflows?*').as('getWorkflows'); cy.intercept('GET', '/rest/workflows?*').as('getWorkflows');
cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows'); cy.intercept('GET', '/rest/active-workflows').as('getActiveWorkflows');
cy.intercept('GET', '/rest/projects').as('getProjects'); cy.intercept('GET', '/rest/projects').as('getProjects');

View File

@@ -14,7 +14,7 @@ const skipValidator = z
message: 'Param `skip` must be a non-negative integer', message: 'Param `skip` must be a non-negative integer',
}); });
export const createTakeValidator = (maxItems: number) => export const createTakeValidator = (maxItems: number, allowInfinity: boolean = false) =>
z z
.string() .string()
.optional() .optional()
@@ -22,9 +22,15 @@ export const createTakeValidator = (maxItems: number) =>
.refine((val) => !isNaN(val) && Number.isInteger(val), { .refine((val) => !isNaN(val) && Number.isInteger(val), {
message: 'Param `take` must be a valid integer', message: 'Param `take` must be a valid integer',
}) })
.refine((val) => val >= 0, { .refine(
message: 'Param `take` must be a non-negative integer', (val) => {
}) if (!allowInfinity) return val >= 0;
return true;
},
{
message: 'Param `take` must be a non-negative integer',
},
)
.transform((val) => Math.min(val, maxItems)); .transform((val) => Math.min(val, maxItems));
export const paginationSchema = { export const paginationSchema = {

View File

@@ -14,14 +14,19 @@ describe('UsersListFilterDto', () => {
parsedResult: { skip: 5, take: 20 }, parsedResult: { skip: 5, take: 20 },
}, },
{ {
name: 'sort by name ascending', name: 'sort by firstName ascending',
request: { sortBy: 'name:asc' }, request: { sortBy: ['firstName:asc'] },
parsedResult: { skip: 0, take: 10, sortBy: 'name:asc' }, parsedResult: { skip: 0, take: 10, sortBy: ['firstName:asc'] },
}, },
{ {
name: 'sort by last active descending and pagination', name: 'sort by lastName ascending',
request: { skip: '5', take: '20', sortBy: 'lastActive:desc' }, request: { sortBy: ['lastName:asc'] },
parsedResult: { skip: 5, take: 20, sortBy: 'lastActive:desc' }, parsedResult: { skip: 0, take: 10, sortBy: ['lastName:asc'] },
},
{
name: 'sort by role descending and pagination',
request: { skip: '5', take: '20', sortBy: ['role:desc'] },
parsedResult: { skip: 5, take: 20, sortBy: ['role:desc'] },
}, },
])('should validate $name', ({ request, parsedResult }) => { ])('should validate $name', ({ request, parsedResult }) => {
const result = UsersListFilterDto.safeParse(request); const result = UsersListFilterDto.safeParse(request);

View File

@@ -1,25 +1,75 @@
import { jsonParse } from 'n8n-workflow';
import { z } from 'zod'; import { z } from 'zod';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { paginationSchema } from '../pagination/pagination.dto'; import { createTakeValidator, paginationSchema } from '../pagination/pagination.dto';
const USERS_LIST_SORT_OPTIONS = [ const USERS_LIST_SORT_OPTIONS = [
'name:asc', 'firstName:asc',
'name:desc', 'firstName:desc',
'lastName:asc',
'lastName: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',
'lastActive:asc', // 'lastActive:asc',
'lastActive:desc', // 'lastActive:desc',
] as const; ] as const;
const usersListSortByValidator = z const usersListSortByValidator = z
.enum(USERS_LIST_SORT_OPTIONS, { .array(
message: `sortBy must be one of: ${USERS_LIST_SORT_OPTIONS.join(', ')}`, z.enum(USERS_LIST_SORT_OPTIONS, {
}) message: `sortBy must be one of: ${USERS_LIST_SORT_OPTIONS.join(', ')}`,
}),
)
.optional(); .optional();
const userSelectSchema = z.array(
z.enum(['id', 'firstName', 'lastName', 'email', 'disabled', 'mfaEnabled', 'role']),
);
const userFilterSchema = z.object({
isOwner: z.boolean().optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().optional(),
fullText: z.string().optional(), // Full text search across firstName, lastName, and email
});
const filterValidatorSchema = z
.string()
.optional()
.transform((val, ctx) => {
if (!val) return undefined;
try {
const parsed: unknown = jsonParse(val);
try {
return userFilterSchema.parse(parsed);
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter fields',
path: ['filter'],
});
return z.NEVER;
}
} catch (e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid filter format',
path: ['filter'],
});
return z.NEVER;
}
});
const userExpandSchema = z.array(z.enum(['projectRelations']));
export class UsersListFilterDto extends Z.class({ export class UsersListFilterDto extends Z.class({
...paginationSchema, ...paginationSchema,
take: createTakeValidator(50, true), // Limit to 50 items per page, and allow infinity for pagination
select: userSelectSchema.optional(),
filter: filterValidatorSchema.optional(),
expand: userExpandSchema.optional(),
// Default sort order is role:asc, secondary sort criteria is name:asc // Default sort order is role:asc, secondary sort criteria is name:asc
sortBy: usersListSortByValidator, sortBy: usersListSortByValidator,
}) {} }) {}

View File

@@ -41,5 +41,7 @@ export {
export { export {
ROLE, ROLE,
type Role, type Role,
type User,
type UsersList, type UsersList,
usersListSchema,
} from './schemas/user.schema'; } from './schemas/user.schema';

View File

@@ -31,16 +31,11 @@ describe('user.schema', () => {
isValid: true, isValid: true,
}, },
{ {
name: 'user with null fields', name: 'user with undefined fields',
data: { data: {
id: '123', id: '123',
firstName: null,
lastName: null,
email: null,
role: 'global:member', role: 'global:member',
isPending: false, isPending: false,
lastActive: null,
projects: null,
}, },
isValid: true, isValid: true,
}, },
@@ -96,7 +91,7 @@ describe('user.schema', () => {
name: 'valid users list', name: 'valid users list',
data: { data: {
count: 2, count: 2,
data: [ items: [
{ {
id: '123', id: '123',
firstName: 'John', firstName: 'John',
@@ -104,7 +99,6 @@ describe('user.schema', () => {
email: 'johndoe@example.com', email: 'johndoe@example.com',
role: 'global:member', role: 'global:member',
isPending: false, isPending: false,
lastActive: '2023-10-01T12:00:00Z',
projects: ['project1', 'project2'], projects: ['project1', 'project2'],
}, },
{ {
@@ -114,7 +108,6 @@ describe('user.schema', () => {
email: 'janedoe@example.com', email: 'janedoe@example.com',
role: 'global:admin', role: 'global:admin',
isPending: true, isPending: true,
lastActive: '2023-10-02T12:00:00Z',
projects: null, projects: null,
}, },
], ],
@@ -125,14 +118,14 @@ describe('user.schema', () => {
name: 'empty users list', name: 'empty users list',
data: { data: {
count: 0, count: 0,
data: [], items: [],
}, },
isValid: true, isValid: true,
}, },
{ {
name: 'missing count', name: 'missing count',
data: { data: {
data: [], items: [],
}, },
isValid: false, isValid: false,
}, },
@@ -147,7 +140,7 @@ describe('user.schema', () => {
name: 'invalid user in list', name: 'invalid user in list',
data: { data: {
count: 1, count: 1,
data: [ items: [
{ {
id: '123', id: '123',
firstName: 'John', firstName: 'John',

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const npsSurveyRespondedSchema = z.object({
lastShownAt: z.number(),
responded: z.literal(true),
});
export const npsSurveyWaitingSchema = z.object({
lastShownAt: z.number(),
waitingForResponse: z.literal(true),
ignoredCount: z.number(),
});
export const npsSurveySchema = z.union([npsSurveyRespondedSchema, npsSurveyWaitingSchema]);
export const userSettingsSchema = z.object({
isOnboarded: z.boolean().optional(),
firstSuccessfulWorkflowId: z.string().optional(),
userActivated: z.boolean().optional(),
userActivatedAt: z.number().optional(),
allowSSOManualLogin: z.boolean().optional(),
npsSurvey: npsSurveySchema.optional(),
easyAIWorkflowOnboarded: z.boolean().optional(),
userClaimedAiCredits: z.boolean().optional(),
dismissedCallouts: z.record(z.boolean()).optional(),
});
export type UserSettings = z.infer<typeof userSettingsSchema>;

View File

@@ -1,5 +1,8 @@
import { projectRoleSchema } from '@n8n/permissions';
import { z } from 'zod'; import { z } from 'zod';
import { userSettingsSchema } from './user-settings.schema';
export const ROLE = { export const ROLE = {
Owner: 'global:owner', Owner: 'global:owner',
Member: 'global:member', Member: 'global:member',
@@ -13,20 +16,31 @@ export type Role = (typeof ROLE)[keyof typeof ROLE];
const roleValuesForSchema = Object.values(ROLE) as [Role, ...Role[]]; const roleValuesForSchema = Object.values(ROLE) as [Role, ...Role[]];
export const roleSchema = z.enum(roleValuesForSchema); export const roleSchema = z.enum(roleValuesForSchema);
export const userProjectSchema = z.object({
id: z.string(),
role: projectRoleSchema,
name: z.string(),
});
export const userListItemSchema = z.object({ export const userListItemSchema = z.object({
id: z.string(), id: z.string(),
firstName: z.string().nullable(), firstName: z.string().nullable().optional(),
lastName: z.string().nullable(), lastName: z.string().nullable().optional(),
email: z.string().email().nullable(), email: z.string().email().nullable().optional(),
role: roleSchema, role: roleSchema.optional(),
isPending: z.boolean(), isPending: z.boolean().optional(),
lastActive: z.string().nullable(), isOwner: z.boolean().optional(),
projects: z.array(z.string()).nullable(), // Can be null if the user is the owner or is an admin signInType: z.string().optional(),
settings: userSettingsSchema.nullable().optional(),
personalizationAnswers: z.object({}).passthrough().nullable().optional(),
lastActive: z.string().optional(),
projectRelations: z.array(userProjectSchema).nullable().optional(),
}); });
export const usersListSchema = z.object({ export const usersListSchema = z.object({
count: z.number(), count: z.number(),
data: z.array(userListItemSchema), items: z.array(userListItemSchema),
}); });
export type User = z.infer<typeof userListItemSchema>;
export type UsersList = z.infer<typeof usersListSchema>; export type UsersList = z.infer<typeof usersListSchema>;

View File

@@ -21,6 +21,7 @@
"dist/**/*" "dist/**/*"
], ],
"dependencies": { "dependencies": {
"@n8n/api-types": "workspace:^",
"@n8n/backend-common": "workspace:^", "@n8n/backend-common": "workspace:^",
"@n8n/config": "workspace:^", "@n8n/config": "workspace:^",
"@n8n/constants": "workspace:^", "@n8n/constants": "workspace:^",
@@ -42,4 +43,4 @@
"@n8n/typescript-config": "workspace:*", "@n8n/typescript-config": "workspace:*",
"@types/lodash": "catalog:" "@types/lodash": "catalog:"
} }
} }

View File

@@ -1,10 +1,10 @@
import type { UsersListFilterDto } from '@n8n/api-types';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { GlobalRole } from '@n8n/permissions'; import type { GlobalRole } from '@n8n/permissions';
import type { DeepPartial, EntityManager, FindManyOptions } from '@n8n/typeorm'; import type { DeepPartial, EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm'; import { Brackets, DataSource, In, IsNull, Not, Repository } from '@n8n/typeorm';
import { Project, ProjectRelation, User } from '../entities'; import { Project, ProjectRelation, User } from '../entities';
import type { ListQuery } from '../entities/types-db';
@Service() @Service()
export class UserRepository extends Repository<User> { export class UserRepository extends Repository<User> {
@@ -75,41 +75,6 @@ export class UserRepository extends Repository<User> {
); );
} }
async toFindManyOptions(listQueryOptions?: ListQuery.Options) {
const findManyOptions: FindManyOptions<User> = {};
if (!listQueryOptions) {
findManyOptions.relations = ['authIdentities'];
return findManyOptions;
}
const { filter, select, take, skip } = listQueryOptions;
if (select) findManyOptions.select = select;
if (take) findManyOptions.take = take;
if (skip) findManyOptions.skip = skip;
if (take && !select) {
findManyOptions.relations = ['authIdentities'];
}
if (take && select && !select?.id) {
findManyOptions.select = { ...findManyOptions.select, id: true }; // pagination requires id
}
if (filter) {
const { isOwner, ...otherFilters } = filter;
findManyOptions.where = otherFilters;
if (isOwner !== undefined) {
findManyOptions.where.role = isOwner ? 'global:owner' : Not('global:owner');
}
}
return findManyOptions;
}
/** /**
* Get emails of users who have completed setup, by user IDs. * Get emails of users who have completed setup, by user IDs.
*/ */
@@ -181,4 +146,141 @@ export class UserRepository extends Repository<User> {
}, },
}); });
} }
private applyUserListSelect(
queryBuilder: SelectQueryBuilder<User>,
select: Array<keyof User> | undefined,
): SelectQueryBuilder<User> {
if (select !== undefined) {
if (!select.includes('id')) {
select.unshift('id'); // Ensure id is always selected
}
queryBuilder.select(select.map((field) => `user.${field}`));
}
return queryBuilder;
}
private applyUserListFilter(
queryBuilder: SelectQueryBuilder<User>,
filter: UsersListFilterDto['filter'],
): SelectQueryBuilder<User> {
if (filter?.email !== undefined) {
queryBuilder.andWhere('user.email = :email', {
email: filter.email,
});
}
if (filter?.firstName !== undefined) {
queryBuilder.andWhere('user.firstName = :firstName', {
firstName: filter.firstName,
});
}
if (filter?.lastName !== undefined) {
queryBuilder.andWhere('user.lastName = :lastName', {
lastName: filter.lastName,
});
}
if (filter?.isOwner !== undefined) {
if (filter.isOwner) {
queryBuilder.andWhere('user.role = :role', {
role: 'global:owner',
});
} else {
queryBuilder.andWhere('user.role <> :role', {
role: 'global:owner',
});
}
}
if (filter?.fullText !== undefined) {
const fullTextFilter = `%${filter.fullText}%`;
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where('LOWER(user.firstName) like LOWER(:firstNameFullText)', {
firstNameFullText: fullTextFilter,
})
.orWhere('LOWER(user.lastName) like LOWER(:lastNameFullText)', {
lastNameFullText: fullTextFilter,
})
.orWhere('LOWER(user.email) like LOWER(:email)', {
email: fullTextFilter,
});
}),
);
}
return queryBuilder;
}
private applyUserListExpand(
queryBuilder: SelectQueryBuilder<User>,
expand: UsersListFilterDto['expand'],
): SelectQueryBuilder<User> {
if (expand?.includes('projectRelations')) {
queryBuilder.leftJoinAndSelect(
'user.projectRelations',
'projectRelations',
'projectRelations.role <> :projectRole',
{
projectRole: 'project:personalOwner', // Exclude personal project relations
},
);
queryBuilder.leftJoinAndSelect('projectRelations.project', 'project');
}
return queryBuilder;
}
private applyUserListSort(
queryBuilder: SelectQueryBuilder<User>,
sortBy: UsersListFilterDto['sortBy'],
): SelectQueryBuilder<User> {
if (sortBy) {
for (const sort of sortBy) {
const [field, order] = sort.split(':');
if (field === 'firstName' || field === 'lastName') {
queryBuilder.addOrderBy(`user.${field}`, order.toUpperCase() as 'ASC' | 'DESC');
} else if (field === 'role') {
queryBuilder.addOrderBy(
"CASE WHEN user.role='global:owner' THEN 0 WHEN user.role='global:admin' THEN 1 ELSE 2 END",
order.toUpperCase() as 'ASC' | 'DESC',
);
}
}
}
return queryBuilder;
}
private applyUserListPagination(
queryBuilder: SelectQueryBuilder<User>,
take: number,
skip: number | undefined,
): SelectQueryBuilder<User> {
if (take >= 0) queryBuilder.limit(take);
if (skip) queryBuilder.offset(skip);
return queryBuilder;
}
buildUserQuery(listQueryOptions?: UsersListFilterDto): SelectQueryBuilder<User> {
const queryBuilder = this.createQueryBuilder('user');
queryBuilder.leftJoinAndSelect('user.authIdentities', 'authIdentities');
if (listQueryOptions === undefined) {
return queryBuilder;
}
const { filter, select, take, skip, expand, sortBy } = listQueryOptions;
this.applyUserListSelect(queryBuilder, select as Array<keyof User>);
this.applyUserListFilter(queryBuilder, filter);
this.applyUserListExpand(queryBuilder, expand);
this.applyUserListPagination(queryBuilder, take, skip);
this.applyUserListSort(queryBuilder, sortBy);
return queryBuilder;
}
} }

View File

@@ -1,4 +1,9 @@
import { RoleChangeRequestDto, SettingsUpdateRequestDto } from '@n8n/api-types'; import {
RoleChangeRequestDto,
SettingsUpdateRequestDto,
UsersListFilterDto,
usersListSchema,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import type { PublicUser } from '@n8n/db'; import type { PublicUser } from '@n8n/db';
import { import {
@@ -19,6 +24,7 @@ import {
Licensed, Licensed,
Body, Body,
Param, Param,
Query,
} from '@n8n/decorators'; } from '@n8n/decorators';
import { Response } from 'express'; import { Response } from 'express';
@@ -29,8 +35,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import { listQueryMiddleware } from '@/middlewares'; import { AuthenticatedRequest, UserRequest } from '@/requests';
import { ListQuery, AuthenticatedRequest, UserRequest } from '@/requests';
import { FolderService } from '@/services/folder.service'; import { FolderService } from '@/services/folder.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
@@ -64,20 +69,16 @@ export class UsersController {
private removeSupplementaryFields( private removeSupplementaryFields(
publicUsers: Array<Partial<PublicUser>>, publicUsers: Array<Partial<PublicUser>>,
listQueryOptions: ListQuery.Options, listQueryOptions: UsersListFilterDto,
) { ) {
const { take, select, filter } = listQueryOptions; const { select } = listQueryOptions;
// remove fields added to satisfy query // remove fields added to satisfy query
if (take && select && !select?.id) { if (select !== undefined && !select.includes('id')) {
for (const user of publicUsers) delete user.id; for (const user of publicUsers) delete user.id;
} }
if (filter?.isOwner) {
for (const user of publicUsers) delete user.role;
}
// remove computed fields (unselectable) // remove computed fields (unselectable)
if (select) { if (select) {
@@ -91,25 +92,40 @@ export class UsersController {
return publicUsers; return publicUsers;
} }
@Get('/', { middlewares: listQueryMiddleware }) @Get('/')
@GlobalScope('user:list') @GlobalScope('user:list')
async listUsers(req: ListQuery.Request) { async listUsers(
const { listQueryOptions } = req; req: AuthenticatedRequest,
_res: Response,
@Query listQueryOptions: UsersListFilterDto,
) {
const userQuery = this.userRepository.buildUserQuery(listQueryOptions);
const findManyOptions = await this.userRepository.toFindManyOptions(listQueryOptions); const response = await userQuery.getManyAndCount();
const users = await this.userRepository.find(findManyOptions); const [users, count] = response;
const publicUsers: Array<Partial<PublicUser>> = await Promise.all( const publicUsers = await Promise.all(
users.map( users.map(async (u) => {
async (u) => const user = await this.userService.toPublic(u, {
await this.userService.toPublic(u, { withInviteUrl: true, inviterId: req.user.id }), withInviteUrl: true,
), inviterId: req.user.id,
});
return {
...user,
projectRelations: u.projectRelations?.map((pr) => ({
id: pr.projectId,
role: pr.role, // normalize role for frontend
name: pr.project.name,
})),
};
}),
); );
return listQueryOptions return usersListSchema.parse({
? this.removeSupplementaryFields(publicUsers, listQueryOptions) count,
: publicUsers; items: this.removeSupplementaryFields(publicUsers, listQueryOptions),
});
} }
@Get('/:id/password-reset-link') @Get('/:id/password-reset-link')

View File

@@ -99,6 +99,7 @@ export const setupTestServer = ({
const app = express(); const app = express();
app.use(rawBodyReader); app.use(rawBodyReader);
app.use(cookieParser()); app.use(cookieParser());
app.set('query parser', 'extended');
app.use((req: APIRequest, _, next) => { app.use((req: APIRequest, _, next) => {
req.browserId = browserId; req.browserId = browserId;
next(); next();

View File

@@ -22,7 +22,7 @@ import {
shareCredentialWithUsers, shareCredentialWithUsers,
} from './shared/db/credentials'; } from './shared/db/credentials';
import { createTeamProject, getPersonalProject, linkUserToProject } from './shared/db/projects'; import { createTeamProject, getPersonalProject, linkUserToProject } from './shared/db/projects';
import { createAdmin, createMember, createOwner, getUserById } from './shared/db/users'; import { createAdmin, createMember, createOwner, createUser, getUserById } from './shared/db/users';
import { createWorkflow, getWorkflowById, shareWorkflowWithUsers } from './shared/db/workflows'; import { createWorkflow, getWorkflowById, shareWorkflowWithUsers } from './shared/db/workflows';
import { randomCredentialPayload } from './shared/random'; import { randomCredentialPayload } from './shared/random';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
@@ -41,15 +41,39 @@ const testServer = utils.setupTestServer({
describe('GET /users', () => { describe('GET /users', () => {
let owner: User; let owner: User;
let member: User; let member1: User;
let ownerAgent: SuperAgentTest; let ownerAgent: SuperAgentTest;
let userRepository: UserRepository;
beforeAll(async () => { beforeAll(async () => {
await testDb.truncate(['User']); await testDb.truncate(['User']);
owner = await createOwner(); userRepository = Container.get(UserRepository);
member = await createMember();
await createMember(); owner = await createUser({
role: 'global:owner',
email: 'owner@n8n.io',
firstName: 'OwnerFirstName',
lastName: 'OwnerLastName',
});
member1 = await createUser({
role: 'global:member',
email: 'member1@n8n.io',
firstName: 'Member1FirstName',
lastName: 'Member1LastName',
});
await createUser({
role: 'global:member',
email: 'member2@n8n.io',
firstName: 'Member2FirstName',
lastName: 'Member2LastName',
});
await createUser({
role: 'global:admin',
email: 'admin@n8n.io',
firstName: 'AdminFirstName',
lastName: 'AdminLastName',
});
ownerAgent = testServer.authAgentFor(owner); ownerAgent = testServer.authAgentFor(owner);
}); });
@@ -57,9 +81,11 @@ describe('GET /users', () => {
test('should return all users', async () => { test('should return all users', async () => {
const response = await ownerAgent.get('/users').expect(200); const response = await ownerAgent.get('/users').expect(200);
expect(response.body.data).toHaveLength(3); expect(response.body.data).toHaveProperty('count', 4);
expect(response.body.data).toHaveProperty('items');
expect(response.body.data.items).toHaveLength(4);
response.body.data.forEach(validateUser); response.body.data.items.forEach(validateUser);
}); });
describe('list query options', () => { describe('list query options', () => {
@@ -67,61 +93,85 @@ describe('GET /users', () => {
test('should filter users by field: email', async () => { test('should filter users by field: email', async () => {
const response = await ownerAgent const response = await ownerAgent
.get('/users') .get('/users')
.query(`filter={ "email": "${member.email}" }`) .query(`filter={ "email": "${member1.email}" }`)
.expect(200); .expect(200);
expect(response.body.data).toHaveLength(1); expect(response.body.data).toEqual({
count: 1,
items: expect.arrayContaining([]),
});
expect(response.body.data.items).toHaveLength(1);
const [user] = response.body.data; const [user] = response.body.data.items;
expect(user.email).toBe(member.email); expect(user.email).toBe(member1.email);
const _response = await ownerAgent const _response = await ownerAgent
.get('/users') .get('/users')
.query('filter={ "email": "non@existing.com" }') .query('filter={ "email": "non@existing.com" }')
.expect(200); .expect(200);
expect(_response.body.data).toHaveLength(0); expect(_response.body.data).toEqual({
count: 0,
items: expect.arrayContaining([]),
});
expect(_response.body.data.items).toHaveLength(0);
}); });
test('should filter users by field: firstName', async () => { test('should filter users by field: firstName', async () => {
const response = await ownerAgent const response = await ownerAgent
.get('/users') .get('/users')
.query(`filter={ "firstName": "${member.firstName}" }`) .query(`filter={ "firstName": "${member1.firstName}" }`)
.expect(200); .expect(200);
expect(response.body.data).toHaveLength(1); expect(response.body.data).toEqual({
count: 1,
items: expect.arrayContaining([]),
});
expect(response.body.data.items).toHaveLength(1);
const [user] = response.body.data; const [user] = response.body.data.items;
expect(user.email).toBe(member.email); expect(user.email).toBe(member1.email);
const _response = await ownerAgent const _response = await ownerAgent
.get('/users') .get('/users')
.query('filter={ "firstName": "Non-Existing" }') .query('filter={ "firstName": "Non-Existing" }')
.expect(200); .expect(200);
expect(_response.body.data).toHaveLength(0); expect(_response.body.data).toEqual({
count: 0,
items: expect.arrayContaining([]),
});
expect(_response.body.data.items).toHaveLength(0);
}); });
test('should filter users by field: lastName', async () => { test('should filter users by field: lastName', async () => {
const response = await ownerAgent const response = await ownerAgent
.get('/users') .get('/users')
.query(`filter={ "lastName": "${member.lastName}" }`) .query(`filter={ "lastName": "${member1.lastName}" }`)
.expect(200); .expect(200);
expect(response.body.data).toHaveLength(1); expect(response.body.data).toEqual({
count: 1,
items: expect.arrayContaining([]),
});
expect(response.body.data.items).toHaveLength(1);
const [user] = response.body.data; const [user] = response.body.data.items;
expect(user.email).toBe(member.email); expect(user.email).toBe(member1.email);
const _response = await ownerAgent const _response = await ownerAgent
.get('/users') .get('/users')
.query('filter={ "lastName": "Non-Existing" }') .query('filter={ "lastName": "Non-Existing" }')
.expect(200); .expect(200);
expect(_response.body.data).toHaveLength(0); expect(_response.body.data).toEqual({
count: 0,
items: expect.arrayContaining([]),
});
expect(_response.body.data.items).toHaveLength(0);
}); });
test('should filter users by computed field: isOwner', async () => { test('should filter users by computed field: isOwner', async () => {
@@ -130,9 +180,13 @@ describe('GET /users', () => {
.query('filter={ "isOwner": true }') .query('filter={ "isOwner": true }')
.expect(200); .expect(200);
expect(response.body.data).toHaveLength(1); expect(response.body.data).toEqual({
count: 1,
items: expect.arrayContaining([]),
});
expect(response.body.data.items).toHaveLength(1);
const [user] = response.body.data; const [user] = response.body.data.items;
expect(user.isOwner).toBe(true); expect(user.isOwner).toBe(true);
@@ -141,60 +195,183 @@ describe('GET /users', () => {
.query('filter={ "isOwner": false }') .query('filter={ "isOwner": false }')
.expect(200); .expect(200);
expect(_response.body.data).toHaveLength(2); expect(_response.body.data).toEqual({
count: 3,
items: expect.arrayContaining([]),
});
expect(_response.body.data.items).toHaveLength(3);
const [_user] = _response.body.data; const [_user] = _response.body.data.items;
expect(_user.isOwner).toBe(false); expect(_user.isOwner).toBe(false);
}); });
test('should filter users by field: fullText', async () => {
const response = await ownerAgent
.get('/users')
.query('filter={ "fullText": "member1" }')
.expect(200);
expect(response.body.data).toEqual({
count: 1,
items: expect.arrayContaining([]),
});
expect(response.body.data.items).toHaveLength(1);
const [user] = response.body.data.items;
expect(user.email).toBe(member1.email);
const _response = await ownerAgent
.get('/users')
.query('filter={ "fullText": "Non-Existing" }')
.expect(200);
expect(_response.body.data).toEqual({
count: 0,
items: expect.arrayContaining([]),
});
expect(_response.body.data.items).toHaveLength(0);
});
}); });
describe('select', () => { describe('select', () => {
test('should select user field: id', async () => { test('should select user field: id', async () => {
const response = await ownerAgent.get('/users').query('select=["id"]').expect(200); const response = await ownerAgent.get('/users').query('select[]=id').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
data: [ data: {
{ id: expect.any(String) }, count: 4,
{ id: expect.any(String) }, items: [
{ id: expect.any(String) }, { id: expect.any(String) },
], { id: expect.any(String) },
{ id: expect.any(String) },
{ id: expect.any(String) },
],
},
}); });
}); });
test('should select user field: email', async () => { test('should select user field: email', async () => {
const response = await ownerAgent.get('/users').query('select=["email"]').expect(200); const response = await ownerAgent.get('/users').query('select[]=email').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
data: [ data: {
{ email: expect.any(String) }, count: 4,
{ email: expect.any(String) }, items: [
{ email: expect.any(String) }, {
], id: expect.any(String),
email: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
},
],
},
}); });
}); });
test('should select user field: firstName', async () => { test('should select user field: firstName', async () => {
const response = await ownerAgent.get('/users').query('select=["firstName"]').expect(200); const response = await ownerAgent.get('/users').query('select[]=firstName').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
data: [ data: {
{ firstName: expect.any(String) }, count: 4,
{ firstName: expect.any(String) }, items: [
{ firstName: expect.any(String) }, {
], id: expect.any(String),
firstName: expect.any(String),
},
{
id: expect.any(String),
firstName: expect.any(String),
},
{
id: expect.any(String),
firstName: expect.any(String),
},
{
id: expect.any(String),
firstName: expect.any(String),
},
],
},
}); });
}); });
test('should select user field: lastName', async () => { test('should select user field: lastName', async () => {
const response = await ownerAgent.get('/users').query('select=["lastName"]').expect(200); const response = await ownerAgent.get('/users').query('select[]=lastName').expect(200);
expect(response.body).toEqual({ expect(response.body).toEqual({
data: [ data: {
{ lastName: expect.any(String) }, count: 4,
{ lastName: expect.any(String) }, items: [
{ lastName: expect.any(String) }, {
], id: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
lastName: expect.any(String),
},
],
},
});
});
test('should select multiple user fields: email, firstName, lastName', async () => {
const response = await ownerAgent
.get('/users')
.query('select[]=email&select[]=firstName&select[]=lastName')
.expect(200);
expect(response.body).toEqual({
data: {
count: 4,
items: [
{
id: expect.any(String),
email: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
},
{
id: expect.any(String),
email: expect.any(String),
firstName: expect.any(String),
lastName: expect.any(String),
},
],
},
}); });
}); });
}); });
@@ -203,23 +380,48 @@ describe('GET /users', () => {
test('should return n users or less, without skip', async () => { test('should return n users or less, without skip', async () => {
const response = await ownerAgent.get('/users').query('take=2').expect(200); const response = await ownerAgent.get('/users').query('take=2').expect(200);
expect(response.body.data).toHaveLength(2); expect(response.body.data.count).toBe(4);
expect(response.body.data.items).toHaveLength(2);
response.body.data.forEach(validateUser); response.body.data.items.forEach(validateUser);
const _response = await ownerAgent.get('/users').query('take=1').expect(200); const _response = await ownerAgent.get('/users').query('take=1').expect(200);
expect(_response.body.data).toHaveLength(1); expect(_response.body.data.items).toHaveLength(1);
_response.body.data.forEach(validateUser); _response.body.data.items.forEach(validateUser);
}); });
test('should return n users or less, with skip', async () => { test('should return n users or less, with skip', async () => {
const response = await ownerAgent.get('/users').query('take=1&skip=1').expect(200); const response = await ownerAgent.get('/users').query('take=1&skip=1').expect(200);
expect(response.body.data).toHaveLength(1); expect(response.body.data.count).toBe(4);
expect(response.body.data.items).toHaveLength(1);
response.body.data.forEach(validateUser); response.body.data.items.forEach(validateUser);
});
test('should return all users with negative take', async () => {
const users: User[] = [];
for (let i = 0; i < 100; i++) {
users.push(await createMember());
}
const response = await ownerAgent.get('/users').query('take=-1').expect(200);
expect(response.body.data.items).toHaveLength(104);
response.body.data.items.forEach(validateUser);
for (const user of users) {
await userRepository.delete({ id: user.id });
}
const _response = await ownerAgent.get('/users').query('take=-1').expect(200);
expect(_response.body.data.items).toHaveLength(4);
_response.body.data.items.forEach(validateUser);
}); });
}); });
@@ -232,10 +434,189 @@ describe('GET /users', () => {
test('should support options that require auxiliary fields', async () => { test('should support options that require auxiliary fields', async () => {
const response = await ownerAgent const response = await ownerAgent
.get('/users') .get('/users')
.query('filter={ "isOwner": true }&select=["firstName"]&take=1') .query('filter={ "isOwner": true }&select[]=firstName&take=1')
.expect(200); .expect(200);
expect(response.body).toEqual({ data: [{ firstName: expect.any(String) }] }); expect(response.body).toEqual({
data: {
count: 1,
items: [
{
id: expect.any(String),
firstName: expect.any(String),
},
],
},
});
});
});
describe('expand', () => {
test('should expand on team projects', async () => {
const project = await createTeamProject('Test Project');
await linkUserToProject(member1, project, 'project:admin');
const response = await ownerAgent
.get('/users')
.query(
`filter={ "email": "${member1.email}" }&select[]=firstName&take=1&expand[]=projectRelations&sortBy[]=role:asc`,
)
.expect(200);
expect(response.body).toEqual({
data: {
count: 1,
items: [
{
id: expect.any(String),
firstName: expect.any(String),
projectRelations: [
{
id: project.id,
role: 'project:admin',
name: project.name, // Ensure the project name is included
},
],
},
],
},
});
expect(response.body.data.items[0].projectRelations).toHaveLength(1);
});
test('should expand on projects and hide personal projects', async () => {
const response = await ownerAgent
.get('/users')
.query(
'filter={ "isOwner": true }&select[]=firstName&take=1&expand[]=projectRelations&sortBy[]=role:asc',
)
.expect(200);
expect(response.body).toEqual({
data: {
count: 1,
items: [
{
id: expect.any(String),
firstName: expect.any(String),
projectRelations: expect.arrayContaining([]),
},
],
},
});
expect(response.body.data.items[0].projectRelations).toHaveLength(0);
});
});
describe('sortBy', () => {
test('should sort by role:asc', async () => {
const response = await ownerAgent.get('/users').query('sortBy[]=role:asc').expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].role).toBe('global:owner');
expect(response.body.data.items[1].role).toBe('global:admin');
expect(response.body.data.items[2].role).toBe('global:member');
expect(response.body.data.items[3].role).toBe('global:member');
});
test('should sort by role:desc', async () => {
const response = await ownerAgent.get('/users').query('sortBy[]=role:desc').expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].role).toBe('global:member');
expect(response.body.data.items[1].role).toBe('global:member');
expect(response.body.data.items[2].role).toBe('global:admin');
expect(response.body.data.items[3].role).toBe('global:owner');
});
test('should sort by firstName:asc', async () => {
const response = await ownerAgent.get('/users').query('sortBy[]=firstName:asc').expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].firstName).toBe('AdminFirstName');
expect(response.body.data.items[1].firstName).toBe('Member1FirstName');
expect(response.body.data.items[2].firstName).toBe('Member2FirstName');
expect(response.body.data.items[3].firstName).toBe('OwnerFirstName');
});
test('should sort by firstName:desc', async () => {
const response = await ownerAgent
.get('/users')
.query('sortBy[]=firstName:desc')
.expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].firstName).toBe('OwnerFirstName');
expect(response.body.data.items[1].firstName).toBe('Member2FirstName');
expect(response.body.data.items[2].firstName).toBe('Member1FirstName');
expect(response.body.data.items[3].firstName).toBe('AdminFirstName');
});
test('should sort by lastName:asc', async () => {
const response = await ownerAgent.get('/users').query('sortBy[]=lastName:asc').expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].lastName).toBe('AdminLastName');
expect(response.body.data.items[1].lastName).toBe('Member1LastName');
expect(response.body.data.items[2].lastName).toBe('Member2LastName');
expect(response.body.data.items[3].lastName).toBe('OwnerLastName');
});
test('should sort by lastName:desc', async () => {
const response = await ownerAgent.get('/users').query('sortBy[]=lastName:desc').expect(200);
expect(response.body.data.items).toHaveLength(4);
expect(response.body.data.items[0].lastName).toBe('OwnerLastName');
expect(response.body.data.items[1].lastName).toBe('Member2LastName');
expect(response.body.data.items[2].lastName).toBe('Member1LastName');
expect(response.body.data.items[3].lastName).toBe('AdminLastName');
});
test('should sort by firstName and lastName combined', async () => {
const user1 = await createUser({
role: 'global:member',
email: 'memberz1@n8n.io',
firstName: 'ZZZFirstName',
lastName: 'ZZZLastName',
});
const user2 = await createUser({
role: 'global:member',
email: 'memberz2@n8n.io',
firstName: 'ZZZFirstName',
lastName: 'ZZYLastName',
});
const response = await ownerAgent
.get('/users')
.query('sortBy[]=firstName:desc&sortBy[]=lastName:desc')
.expect(200);
expect(response.body.data.items).toHaveLength(6);
expect(response.body.data.items[0].id).toBe(user1.id);
expect(response.body.data.items[1].id).toBe(user2.id);
expect(response.body.data.items[2].lastName).toBe('OwnerLastName');
expect(response.body.data.items[3].lastName).toBe('Member2LastName');
expect(response.body.data.items[4].lastName).toBe('Member1LastName');
expect(response.body.data.items[5].lastName).toBe('AdminLastName');
const response1 = await ownerAgent
.get('/users')
.query('sortBy[]=firstName:asc&sortBy[]=lastName:asc')
.expect(200);
expect(response1.body.data.items).toHaveLength(6);
expect(response1.body.data.items[5].id).toBe(user1.id);
expect(response1.body.data.items[4].id).toBe(user2.id);
expect(response1.body.data.items[3].lastName).toBe('OwnerLastName');
expect(response1.body.data.items[2].lastName).toBe('Member2LastName');
expect(response1.body.data.items[1].lastName).toBe('Member1LastName');
expect(response1.body.data.items[0].lastName).toBe('AdminLastName');
await userRepository.delete({ id: user1.id });
await userRepository.delete({ id: user2.id });
}); });
}); });
}); });

View File

@@ -8,6 +8,7 @@ import type {
IVersionNotificationSettings, IVersionNotificationSettings,
ROLE, ROLE,
Role, Role,
User,
} from '@n8n/api-types'; } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type { NodeCreatorTag } from '@n8n/design-system'; import type { NodeCreatorTag } from '@n8n/design-system';
@@ -53,7 +54,6 @@ import type { Cloud, InstanceUsage } from '@n8n/rest-api-client/api/cloudPlans';
import type { import type {
AI_NODE_CREATOR_VIEW, AI_NODE_CREATOR_VIEW,
CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY,
SignInType,
TRIGGER_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW,
REGULAR_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW, AI_OTHERS_NODE_CREATOR_VIEW,
@@ -571,18 +571,10 @@ export type IPersonalizationSurveyVersions =
export type InvitableRoleName = (typeof ROLE)['Member' | 'Admin']; export type InvitableRoleName = (typeof ROLE)['Member' | 'Admin'];
export interface IUserResponse { export interface IUserResponse extends User {
id: string;
firstName?: string;
lastName?: string;
email?: string;
createdAt?: string;
role?: Role;
globalScopes?: Scope[]; globalScopes?: Scope[];
personalizationAnswers?: IPersonalizationSurveyVersions | null; personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean; settings?: IUserSettings | null;
signInType?: SignInType;
settings?: IUserSettings;
} }
export interface CurrentUserResponse extends IUserResponse { export interface CurrentUserResponse extends IUserResponse {

View File

@@ -2,6 +2,8 @@ import type {
LoginRequestDto, LoginRequestDto,
PasswordUpdateRequestDto, PasswordUpdateRequestDto,
SettingsUpdateRequestDto, SettingsUpdateRequestDto,
UsersList,
UsersListFilterDto,
UserUpdateRequestDto, UserUpdateRequestDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import type { import type {
@@ -126,8 +128,11 @@ export async function deleteUser(
await makeRestApiRequest(context, 'DELETE', `/users/${id}`, transferId ? { transferId } : {}); await makeRestApiRequest(context, 'DELETE', `/users/${id}`, transferId ? { transferId } : {});
} }
export async function getUsers(context: IRestApiContext): Promise<IUserResponse[]> { export async function getUsers(
return await makeRestApiRequest(context, 'GET', '/users'); context: IRestApiContext,
filter?: UsersListFilterDto,
): Promise<UsersList> {
return await makeRestApiRequest(context, 'GET', '/users', filter);
} }
export async function getInviteLink( export async function getInviteLink(

View File

@@ -28,7 +28,7 @@ export const useNpsSurveyStore = defineStore('npsSurvey', () => {
const currentUserId = ref<string | undefined>(); const currentUserId = ref<string | undefined>();
const promptsData = ref<N8nPrompts | undefined>(); const promptsData = ref<N8nPrompts | undefined>();
function setupNpsSurveyOnLogin(userId: string, settings?: IUserSettings): void { function setupNpsSurveyOnLogin(userId: string, settings?: IUserSettings | null): void {
currentUserId.value = userId; currentUserId.value = userId;
if (settings) { if (settings) {

View File

@@ -36,7 +36,6 @@ function setCurrentUser() {
{ {
id: CURRENT_USER_ID, id: CURRENT_USER_ID,
isPending: false, isPending: false,
createdAt: '2023-03-17T14:01:36.432Z',
}, },
]); ]);
@@ -115,7 +114,6 @@ describe('Posthog store', () => {
const userId = `${CURRENT_INSTANCE_ID}#${CURRENT_USER_ID}`; const userId = `${CURRENT_INSTANCE_ID}#${CURRENT_USER_ID}`;
expect(window.posthog?.identify).toHaveBeenCalledWith(userId, { expect(window.posthog?.identify).toHaveBeenCalledWith(userId, {
created_at_timestamp: 1679061696432,
instance_id: CURRENT_INSTANCE_ID, instance_id: CURRENT_INSTANCE_ID,
}); });
}); });

View File

@@ -3,6 +3,7 @@ import {
type PasswordUpdateRequestDto, type PasswordUpdateRequestDto,
type SettingsUpdateRequestDto, type SettingsUpdateRequestDto,
type UserUpdateRequestDto, type UserUpdateRequestDto,
type User,
ROLE, ROLE,
} from '@n8n/api-types'; } from '@n8n/api-types';
import type { UpdateGlobalRolePayload } from '@/api/users'; import type { UpdateGlobalRolePayload } from '@/api/users';
@@ -119,8 +120,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
// Methods // Methods
const addUsers = (newUsers: IUserResponse[]) => { const addUsers = (newUsers: User[]) => {
newUsers.forEach((userResponse: IUserResponse) => { newUsers.forEach((userResponse) => {
const prevUser = usersById.value[userResponse.id] || {}; const prevUser = usersById.value[userResponse.id] || {};
const updatedUser = { const updatedUser = {
...prevUser, ...prevUser,
@@ -309,8 +310,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
}; };
const fetchUsers = async () => { const fetchUsers = async () => {
const users = await usersApi.getUsers(rootStore.restApiContext); const { items } = await usersApi.getUsers(rootStore.restApiContext, { take: -1, skip: 0 });
addUsers(users); addUsers(items);
}; };
const inviteUsers = async (params: Array<{ email: string; role: InvitableRoleName }>) => { const inviteUsers = async (params: Array<{ email: string; role: InvitableRoleName }>) => {

View File

@@ -360,7 +360,10 @@ describe('WorkflowsView', () => {
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]); workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
}); });
it('should reinitialize on source control pullWorkfolder', async () => { it('should reinitialize on source control pullWorkfolder', async () => {
vi.spyOn(usersApi, 'getUsers').mockResolvedValue([]); vi.spyOn(usersApi, 'getUsers').mockResolvedValue({
count: 0,
items: [],
});
const userStore = mockedStore(useUsersStore); const userStore = mockedStore(useUsersStore);
const sourceControl = useSourceControlStore(); const sourceControl = useSourceControlStore();

3
pnpm-lock.yaml generated
View File

@@ -541,6 +541,9 @@ importers:
packages/@n8n/db: packages/@n8n/db:
dependencies: dependencies:
'@n8n/api-types':
specifier: workspace:^
version: link:../api-types
'@n8n/backend-common': '@n8n/backend-common':
specifier: workspace:^ specifier: workspace:^
version: link:../backend-common version: link:../backend-common