refactor(API): Migrate the project member routes to the decorator pattern (no-changelog) (#37979)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Uddish Verma 2026-09-11 13:01:51 +00:00 committed by GitHub
parent 999b541764
commit 697b538c95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 674 additions and 415 deletions

View File

@ -83,6 +83,14 @@ export {
DeleteProjectQueryPublicDto,
} from './project/project-public.dto';
export type { ProjectPublic } from './project/project-public.dto';
export {
projectMemberPublicSchema,
ProjectMemberPublicDto,
ProjectMemberListPublicDto,
ListProjectMembersQueryPublicDto,
AddProjectMembersPublicDto,
ChangeProjectMemberRolePublicDto,
} from './project/project-member-public.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';

View File

@ -0,0 +1,92 @@
import {
AddProjectMembersPublicDto,
ChangeProjectMemberRolePublicDto,
ListProjectMembersQueryPublicDto,
ProjectMemberListPublicDto,
ProjectMemberPublicDto,
} from '../project-member-public.dto';
const member = {
id: '123e4567-e89b-12d3-a456-426614174000',
email: 'john.doe@company.com',
firstName: 'John',
lastName: 'Doe',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
role: 'project:viewer',
};
describe('ProjectMemberPublicDto', () => {
test('accepts all the expected fields', () => {
expect(ProjectMemberPublicDto.safeParse(member).success).toBe(true);
});
test('accepts null names and a null role', () => {
const result = ProjectMemberPublicDto.safeParse({
...member,
firstName: null,
lastName: null,
role: null,
});
expect(result.success).toBe(true);
});
test('strips a field the schema does not name', () => {
const result = ProjectMemberPublicDto.safeParse({ ...member, password: 'secret' });
expect(result.success).toBe(true);
expect(result.data).not.toHaveProperty('password');
});
});
describe('ProjectMemberListPublicDto', () => {
test('accepts a page with a next cursor', () => {
const result = ProjectMemberListPublicDto.safeParse({ data: [member], nextCursor: 'abc' });
expect(result.success).toBe(true);
});
test('accepts the last page', () => {
expect(ProjectMemberListPublicDto.safeParse({ data: [], nextCursor: null }).success).toBe(true);
});
test('rejects a missing nextCursor', () => {
expect(ProjectMemberListPublicDto.safeParse({ data: [] }).success).toBe(false);
});
});
describe('ListProjectMembersQueryPublicDto', () => {
test('exposes limit and cursor and drops offset', () => {
const result = ListProjectMembersQueryPublicDto.safeParse({
limit: '5',
cursor: 'abc',
offset: '3',
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ limit: 5, cursor: 'abc' });
});
});
describe('AddProjectMembersPublicDto', () => {
test('accepts relations and, as the legacy schema did, ignores an unknown key', () => {
const result = AddProjectMembersPublicDto.safeParse({
relations: [{ userId: member.id, role: 'project:viewer', extra: true }],
});
expect(result.success).toBe(true);
expect(result.data).toEqual({ relations: [{ userId: member.id, role: 'project:viewer' }] });
});
test('rejects an empty relations list', () => {
expect(AddProjectMembersPublicDto.safeParse({ relations: [] }).success).toBe(false);
});
});
describe('ChangeProjectMemberRolePublicDto', () => {
test('accepts any non-empty role; the service checks that it exists', () => {
expect(ChangeProjectMemberRolePublicDto.safeParse({ role: 'custom:role' }).success).toBe(true);
expect(ChangeProjectMemberRolePublicDto.safeParse({ role: '' }).success).toBe(false);
});
});

View File

@ -0,0 +1,54 @@
import '../../openapi-extend';
import { z } from 'zod';
import {
addProjectMembersFieldDocs,
projectMemberDocs,
projectMemberFieldDocs,
projectMemberListFieldDocs,
} from './project-member-public.openapi';
import { Z } from '../../zod-class';
import { publicApiPaginationSchema } from '../pagination/pagination.dto';
export const projectMemberPublicSchema = z
.object({
id: z.string().openapi(projectMemberFieldDocs.id),
email: z.string().openapi(projectMemberFieldDocs.email),
firstName: z.string().nullable().openapi(projectMemberFieldDocs.firstName),
lastName: z.string().nullable().openapi(projectMemberFieldDocs.lastName),
createdAt: z.string().datetime().openapi(projectMemberFieldDocs.createdAt),
updatedAt: z.string().datetime().openapi(projectMemberFieldDocs.updatedAt),
role: z.string().nullable().openapi(projectMemberFieldDocs.role),
})
.openapi(projectMemberDocs);
export class ProjectMemberPublicDto extends Z.class(projectMemberPublicSchema.shape) {}
export class ProjectMemberListPublicDto extends Z.class({
data: z.array(projectMemberPublicSchema),
nextCursor: z.string().nullable().openapi(projectMemberListFieldDocs.nextCursor),
}) {}
export class ListProjectMembersQueryPublicDto extends Z.class({
limit: publicApiPaginationSchema.limit,
cursor: z.string().optional(),
}) {}
const projectMemberRoleSchema = z.string().min(1).openapi(addProjectMembersFieldDocs.role);
export class AddProjectMembersPublicDto extends Z.class({
relations: z
.array(
z.object({
userId: z.string().min(1).openapi(addProjectMembersFieldDocs.userId),
role: projectMemberRoleSchema,
}),
)
.min(1)
.openapi(addProjectMembersFieldDocs.relations),
}) {}
export class ChangeProjectMemberRolePublicDto extends Z.class({
role: projectMemberRoleSchema,
}) {}

View File

@ -0,0 +1,56 @@
import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi';
export const projectMemberDocs: ZodOpenAPIMetadata = {
description: 'A project member (user with their role in the project).',
};
export const projectMemberFieldDocs = {
id: {
description: "The user's unique identifier.",
readOnly: true,
example: '123e4567-e89b-12d3-a456-426614174000',
},
email: {
format: 'email',
description: "The user's email address.",
readOnly: true,
example: 'john.doe@company.com',
},
firstName: {
maxLength: 32,
description: "The user's first name.",
readOnly: true,
example: 'john',
},
lastName: {
maxLength: 32,
description: "The user's last name.",
readOnly: true,
example: 'Doe',
},
createdAt: { description: 'When the user was created.', readOnly: true },
updatedAt: { description: 'When the user was last updated.', readOnly: true },
role: {
description: "The user's role in the project (e.g. project:admin, project:viewer).",
readOnly: true,
example: 'project:viewer',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const projectMemberListFieldDocs = {
nextCursor: {
description:
'Paginate through project members by setting the cursor parameter to the nextCursor ' +
'attribute returned by a previous request. Default value fetches the first page of the collection.',
example: 'eyJvZmZzZXQiOjEwMCwibGltaXQiOjEwMH0=',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const addProjectMembersFieldDocs = {
relations: { description: 'A list of userIds and roles to add to the project.' },
userId: {
description: 'The unique identifier of the user.',
example: '91765f0d-3b29-45df-adb9-35b23937eb92',
},
role: { description: 'The role assigned to the user in the project.', example: 'project:viewer' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;

View File

@ -137,6 +137,7 @@ export {
promotionProviderIdParamSchema,
roleMappingRuleIdParamSchema,
roleSlugParamSchema,
userIdParamSchema,
workflowIdParamSchema,
workflowVersionIdParamSchema,
} from './schemas/public-api-path-params.schema';

View File

@ -18,6 +18,7 @@ export const executionIdParamSchema = numericIdParamSchema('The ID of the execut
export const workflowIdParamSchema = stringIdParamSchema('The ID of the workflow.');
export const workflowVersionIdParamSchema = stringIdParamSchema('The ID of the workflow version.');
export const projectIdParamSchema = stringIdParamSchema('The ID of the project.');
export const userIdParamSchema = stringIdParamSchema('The ID of the user.');
export const roleSlugParamSchema = stringIdParamSchema('The slug of the role.');
export const roleMappingRuleIdParamSchema = stringIdParamSchema('The ID of the role mapping rule.');
export const promotionConnectionIdParamSchema = stringIdParamSchema(

View File

@ -160,7 +160,6 @@ export default defineConfig(
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/otel/otel.handler.ts',
'./src/public-api/v1/handlers/projects/projects.handler.ts',
'./src/public-api/v1/handlers/security-policy/security-policy.handler.ts',
'./src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts',
'./src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts',

View File

@ -141,6 +141,23 @@ describe('PublicApiControllerRegistry', () => {
);
});
describe('success response without a DTO', () => {
it('sends an empty body without a content-type when the handler returns nothing', async () => {
@Service()
class WidgetsPublicController {
@Post('/')
@ApiResponse(201)
create() {}
}
markPublicApiController(WidgetsPublicController as Controller, '/widgets');
const response = await request(activate()).post('/api/v1/widgets').expect(201);
expect(response.text).toBe('');
expect(response.headers['content-type']).toBeUndefined();
});
});
describe('validation failures', () => {
class WidgetValidationDto extends Z.class({
name: z.string(),

View File

@ -107,17 +107,14 @@ export class PublicApiControllerRegistry {
if (res.headersSent) return;
if (successStatus === 204) {
res.status(204).send();
if (successStatus === 204 || (!route.responseDto && result === undefined)) {
res.status(successStatus).send();
return;
}
if (route.responseDto) {
res.status(successStatus).json(route.responseDto.parse(result));
return;
}
res.status(successStatus).json(result);
res
.status(successStatus)
.json(route.responseDto ? route.responseDto.parse(result) : result);
};
const middlewares: RequestHandler[] = [];

View File

@ -1,15 +1,21 @@
import {
AddProjectMembersPublicDto,
ChangeProjectMemberRolePublicDto,
CreatedProjectPublicDto,
CreateProjectPublicDto,
DeleteProjectQueryPublicDto,
ListProjectMembersQueryPublicDto,
ListProjectsQueryPublicDto,
ProjectListPublicDto,
ProjectMemberListPublicDto,
ProjectMemberPublicDto,
ProjectPublicDto,
UpdateProjectPublicDto,
projectIdParamSchema,
userIdParamSchema,
} from '@n8n/api-types';
import { LICENSE_FEATURES } from '@n8n/constants';
import type { AuthenticatedRequest, Project } from '@n8n/db';
import type { AuthenticatedRequest, Project, ProjectRelation } from '@n8n/db';
import {
ApiDescription,
ApiErrorResponse,
@ -22,6 +28,7 @@ import {
Get,
Licensed,
Param,
Patch,
Post,
PublicApiController,
Put,
@ -29,6 +36,9 @@ import {
} from '@n8n/decorators';
import type { Response } from 'express';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import {
encodeNextCursor,
resolveOffsetPagination,
@ -49,9 +59,22 @@ const toProjectPublicDto = (project: Project): ProjectPublicDto => ({
updatedAt: project.updatedAt.toISOString(),
});
const toProjectMemberPublicDto = (relation: ProjectRelation): ProjectMemberPublicDto => ({
id: relation.user.id,
email: relation.user.email,
firstName: relation.user.firstName,
lastName: relation.user.lastName,
createdAt: relation.user.createdAt.toISOString(),
updatedAt: relation.user.updatedAt.toISOString(),
role: relation.role?.slug ?? null,
});
@PublicApiController('/projects')
export class ProjectsPublicController {
constructor(private readonly projectService: ProjectService) {}
constructor(
private readonly projectService: ProjectService,
private readonly provisioningService: ProvisioningService,
) {}
@Get('/')
@Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN)
@ -130,4 +153,109 @@ export class ProjectsPublicController {
): Promise<void> {
await this.projectService.deleteProject(req.user, projectId);
}
@Get('/:projectId/users')
@Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN)
@ApiKeyScope('user:list')
@ApiSummary('List project members')
@ApiDescription(
'Returns a list of all members of a project including their role. Requires user:list scope.',
)
@ApiTags(tags)
@ApiResponse(200, ProjectMemberListPublicDto)
@ApiErrorResponse(404)
async getProjectUsers(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', projectIdParamSchema) projectId: string,
@Query query: ListProjectMembersQueryPublicDto,
): Promise<ProjectMemberListPublicDto> {
const { offset, limit } = resolveOffsetPagination(query);
const project = await this.projectService.getProjectWithScope(req.user, projectId, [
'project:list',
]);
if (!project) {
throw new NotFoundError(`Could not find project with ID "${projectId}"`);
}
const { members, count } = await this.projectService.getProjectMembersAndCount(projectId, {
offset,
limit,
});
return {
data: members.map(toProjectMemberPublicDto),
nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }),
};
}
@Post('/:projectId/users')
@Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN)
@ApiKeyScope('project:manageMembers')
@ApiSummary('Add one or more users to a project')
@ApiDescription('Add one or more users to a project on your instance.')
@ApiTags(tags)
@ApiResponse(201)
@ApiErrorResponse(400)
@ApiErrorResponse(404)
async addUsersToProject(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', projectIdParamSchema) projectId: string,
@Body body: AddProjectMembersPublicDto,
): Promise<void> {
await this.assertProjectRolesNotManaged();
await this.projectService.addUsersToProject(req.user, projectId, body.relations);
}
@Patch('/:projectId/users/:userId')
@Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN)
@ApiKeyScope('project:manageMembers')
@ApiSummary("Change a user's role in a project")
@ApiDescription("Change a user's role in a project.")
@ApiTags(tags)
@ApiResponse(204)
@ApiErrorResponse(400)
@ApiErrorResponse(404)
async changeUserRoleInProject(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', projectIdParamSchema) projectId: string,
@Param('userId', userIdParamSchema) userId: string,
@Body body: ChangeProjectMemberRolePublicDto,
): Promise<void> {
await this.assertProjectRolesNotManaged();
await this.projectService.changeUserRoleInProject(req.user, projectId, userId, body.role);
}
@Delete('/:projectId/users/:userId')
@Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN)
@ApiKeyScope('project:manageMembers')
@ApiSummary('Delete a user from a project')
@ApiDescription('Delete a user from a project on your instance.')
@ApiTags(tags)
@ApiResponse(204)
@ApiErrorResponse(404)
async deleteUserFromProject(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId', projectIdParamSchema) projectId: string,
@Param('userId', userIdParamSchema) userId: string,
): Promise<void> {
await this.assertProjectRolesNotManaged();
await this.projectService.deleteUserFromProject(req.user, projectId, userId);
}
/** Mirrors the ProjectController guard: manual membership changes are disallowed when roles are provisioned. */
private async assertProjectRolesNotManaged() {
if (await this.provisioningService.isProjectRoleManaged()) {
throw new ForbiddenError(
'Project roles are managed automatically and cannot be changed manually',
);
}
}
}

View File

@ -1,147 +0,0 @@
import { AddUsersToProjectDto, ChangeUserRoleInProject } from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { Container } from '@n8n/di';
import pick from 'lodash/pick';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
apiKeyHasScopeWithGlobalScopeFallback,
isLicensed,
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import type { PaginatedRequest } from '@/public-api/types';
import { ProjectService } from '@/services/project.service.ee';
type GetProjectUsersRequest = AuthenticatedRequest<{ projectId: string }> & PaginatedRequest;
type ProjectHandlers = {
getProjectUsers: PublicAPIEndpoint<GetProjectUsersRequest>;
addUsersToProject: PublicAPIEndpoint<AuthenticatedRequest<{ projectId: string }>>;
changeUserRoleInProject: PublicAPIEndpoint<
AuthenticatedRequest<{ projectId: string; userId: string }>
>;
deleteUserFromProject: PublicAPIEndpoint<
AuthenticatedRequest<{ projectId: string; userId: string }>
>;
};
/** Mirrors the ProjectController guard: manual membership changes are disallowed when roles are provisioned. */
async function assertProjectRolesNotManaged() {
if (await Container.get(ProvisioningService).isProjectRoleManaged()) {
throw new ForbiddenError(
'Project roles are managed automatically and cannot be changed manually',
);
}
}
const projectHandlers: ProjectHandlers = {
getProjectUsers: [
isLicensed('feat:projectRole:admin'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }),
validCursor,
async (req, res) => {
const { projectId } = req.params;
const offset = Number(req.query.offset) || 0;
const limit = Number(req.query.limit) || 100;
const projectService = Container.get(ProjectService);
const project = await projectService.getProjectWithScope(req.user, projectId, [
'project:list',
]);
if (!project) {
throw new NotFoundError(`Could not find project with ID "${projectId}"`);
}
const { members, count } = await projectService.getProjectMembersAndCount(projectId, {
offset,
limit,
});
const memberFields = [
'id',
'email',
'firstName',
'lastName',
'createdAt',
'updatedAt',
] as const;
const data = members.map((relation) => ({
...pick(relation.user, memberFields),
role: relation.role?.slug ?? null,
}));
return res.json({
data,
nextCursor: encodeNextCursor({
offset,
limit,
numberOfTotalRecords: count,
}),
});
},
],
addUsersToProject: [
isLicensed('feat:projectRole:admin'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }),
async (req, res) => {
await assertProjectRolesNotManaged();
const payload = AddUsersToProjectDto.safeParse(req.body);
if (payload.error) {
throw new BadRequestError(payload.error.errors[0].message);
}
await Container.get(ProjectService).addUsersToProject(
req.user,
req.params.projectId,
payload.data.relations,
);
return res.status(201).send();
},
],
changeUserRoleInProject: [
isLicensed('feat:projectRole:admin'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }),
async (req, res) => {
await assertProjectRolesNotManaged();
const payload = ChangeUserRoleInProject.safeParse(req.body);
if (payload.error) {
throw new BadRequestError(payload.error.errors[0].message);
}
const { projectId, userId } = req.params;
const { role } = payload.data;
await Container.get(ProjectService).changeUserRoleInProject(
req.user,
projectId,
userId,
role,
);
return res.status(204).send();
},
],
deleteUserFromProject: [
isLicensed('feat:projectRole:admin'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }),
async (req, res) => {
await assertProjectRolesNotManaged();
const { projectId, userId } = req.params;
await Container.get(ProjectService).deleteUserFromProject(req.user, projectId, userId);
return res.status(204).send();
},
],
};
export = projectHandlers;

View File

@ -0,0 +1,58 @@
operationId: addUsersToProject
tags:
- Projects
summary: Add one or more users to a project
description: Add one or more users to a project on your instance.
x-required-scope: project:manageMembers
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the project.
name: projectId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
relations:
type: array
items:
type: object
properties:
userId:
type: string
minLength: 1
description: The unique identifier of the user.
example: 91765f0d-3b29-45df-adb9-35b23937eb92
role:
type: string
minLength: 1
description: The role assigned to the user in the project.
example: project:viewer
required:
- userId
- role
minItems: 1
description: A list of userIds and roles to add to the project.
required:
- relations
responses:
'201':
description: Operation successful.
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -0,0 +1,49 @@
operationId: changeUserRoleInProject
tags:
- Projects
summary: Change a user's role in a project
description: Change a user's role in a project.
x-required-scope: project:manageMembers
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the project.
name: projectId
in: path
- schema:
type: string
required: true
description: The ID of the user.
name: userId
in: path
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
role:
type: string
minLength: 1
description: The role assigned to the user in the project.
example: project:viewer
required:
- role
responses:
'204':
description: Operation successful.
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml
'415':
$ref: ../../../../shared/spec/responses/unsupportedMediaType.yml

View File

@ -0,0 +1,31 @@
operationId: deleteUserFromProject
tags:
- Projects
summary: Delete a user from a project
description: Delete a user from a project on your instance.
x-required-scope: project:manageMembers
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
parameters:
- schema:
type: string
required: true
description: The ID of the project.
name: projectId
in: path
- schema:
type: string
required: true
description: The ID of the user.
name: userId
in: path
responses:
'204':
description: Operation successful.
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -0,0 +1,97 @@
operationId: getProjectUsers
tags:
- Projects
summary: List project members
description: Returns a list of all members of a project including their role. Requires user:list scope.
x-required-scope: user:list
parameters:
- $ref: ../../../../shared/spec/parameters/limit.yml
- $ref: ../../../../shared/spec/parameters/cursor.yml
- schema:
type: string
required: true
description: The ID of the project.
name: projectId
in: path
x-eov-operation-id: unreachable
x-eov-operation-handler: v1/handlers/decorator-routed.handler
x-decorator-routed: true
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: The user's unique identifier.
readOnly: true
example: 123e4567-e89b-12d3-a456-426614174000
email:
type: string
format: email
description: The user's email address.
readOnly: true
example: john.doe@company.com
firstName:
type: string
nullable: true
maxLength: 32
description: The user's first name.
readOnly: true
example: john
lastName:
type: string
nullable: true
maxLength: 32
description: The user's last name.
readOnly: true
example: Doe
createdAt:
type: string
format: date-time
description: When the user was created.
readOnly: true
updatedAt:
type: string
format: date-time
description: When the user was last updated.
readOnly: true
role:
type: string
nullable: true
description: The user's role in the project (e.g. project:admin, project:viewer).
readOnly: true
example: project:viewer
required:
- id
- email
- firstName
- lastName
- createdAt
- updatedAt
- role
description: A project member (user with their role in the project).
nextCursor:
type: string
nullable: true
description: Paginate through project members by setting the cursor parameter to the nextCursor attribute returned by a previous request. Default value fetches the first page of the collection.
example: eyJvZmZzZXQiOjEwMCwibGltaXQiOjEwMH0=
required:
- data
- nextCursor
'400':
$ref: ../../../../shared/spec/responses/badRequest.yml
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -1,73 +0,0 @@
delete:
x-eov-operation-id: deleteUserFromProject
x-required-scope: project:manageMembers
x-eov-operation-handler: v1/handlers/projects/projects.handler
tags:
- Projects
summary: Delete a user from a project
description: Delete a user from a project on your instance.
parameters:
- name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string
- name: userId
in: path
description: The ID of the user.
required: true
schema:
type: string
responses:
'204':
description: Operation successful.
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
patch:
x-eov-operation-id: changeUserRoleInProject
x-required-scope: project:manageMembers
x-eov-operation-handler: v1/handlers/projects/projects.handler
tags:
- Projects
summary: Change a user's role in a project
description: Change a user's role in a project.
parameters:
- name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string
- name: userId
in: path
description: The ID of the user.
required: true
schema:
type: string
requestBody:
description: Payload containing the new role to assign to the project user.
content:
application/json:
schema:
type: object
properties:
role:
type: string
description: The role assigned to the user in the project.
example: 'project:viewer'
required:
- role
responses:
'204':
description: Operation successful.
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,80 +0,0 @@
get:
x-eov-operation-id: getProjectUsers
x-required-scope: user:list
x-eov-operation-handler: v1/handlers/projects/projects.handler
tags:
- Projects
summary: List project members
description: Returns a list of all members of a project including their role. Requires user:list scope.
parameters:
- name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string
- $ref: '../../../../shared/spec/parameters/limit.yml'
- $ref: '../../../../shared/spec/parameters/cursor.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/projectMemberList.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
post:
x-eov-operation-id: addUsersToProject
x-required-scope: project:manageMembers
x-eov-operation-handler: v1/handlers/projects/projects.handler
tags:
- Projects
summary: Add one or more users to a project
description: Add one or more users to a project on your instance.
parameters:
- name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string
requestBody:
description: Payload containing an array of one or more users to add to the project.
content:
application/json:
schema:
type: object
properties:
relations:
type: array
description: A list of userIds and roles to add to the project.
items:
type: object
properties:
userId:
type: string
description: The unique identifier of the user.
example: '91765f0d-3b29-45df-adb9-35b23937eb92'
role:
type: string
description: The role assigned to the user in the project.
example: 'project:viewer'
required:
- userId
- role
required:
- relations
responses:
'201':
description: Operation successful.
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,6 +0,0 @@
name: projectId
in: path
description: The ID of the project.
required: true
schema:
type: string

View File

@ -1,13 +0,0 @@
type: object
additionalProperties: false
required:
- name
properties:
id:
type: string
readOnly: true
name:
type: string
type:
type: string
readOnly: true

View File

@ -1,11 +0,0 @@
type: object
properties:
data:
type: array
items:
$ref: './project.yml'
nextCursor:
type: string
description: Paginate through projects by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View File

@ -1,41 +0,0 @@
type: object
description: A project member (user with their role in the project).
properties:
id:
type: string
description: The user's unique identifier.
readOnly: true
example: 123e4567-e89b-12d3-a456-426614174000
email:
type: string
format: email
description: The user's email address.
readOnly: true
example: john.doe@company.com
firstName:
type: string
maxLength: 32
description: The user's first name.
readOnly: true
example: john
lastName:
type: string
maxLength: 32
description: The user's last name.
readOnly: true
example: Doe
createdAt:
type: string
format: date-time
description: When the user was created.
readOnly: true
updatedAt:
type: string
format: date-time
description: When the user was last updated.
readOnly: true
role:
type: string
description: The user's role in the project (e.g. project:admin, project:viewer).
readOnly: true
example: project:viewer

View File

@ -1,11 +0,0 @@
type: object
properties:
data:
type: array
items:
$ref: './projectMember.yml'
nextCursor:
type: string
description: Paginate through project members by setting the cursor parameter to the nextCursor attribute returned by a previous request. Default value fetches the first page of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View File

@ -41,6 +41,16 @@ paths:
$ref: ./handlers/projects/spec/paths/updateProject.generated.yml
delete:
$ref: ./handlers/projects/spec/paths/deleteProject.generated.yml
/projects/{projectId}/users:
get:
$ref: ./handlers/projects/spec/paths/getProjectUsers.generated.yml
post:
$ref: ./handlers/projects/spec/paths/addUsersToProject.generated.yml
/projects/{projectId}/users/{userId}:
patch:
$ref: ./handlers/projects/spec/paths/changeUserRoleInProject.generated.yml
delete:
$ref: ./handlers/projects/spec/paths/deleteUserFromProject.generated.yml
/promotions/providers:
post:
$ref: ./handlers/promotions/spec/paths/createPromotionProvider.generated.yml

View File

@ -143,10 +143,6 @@ paths:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml'
/data-tables/{dataTableId}/columns/{columnId}:
$ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.columns.columnId.yml'
/projects/{projectId}/users:
$ref: './handlers/projects/spec/paths/projects.projectId.users.yml'
/projects/{projectId}/users/{userId}:
$ref: './handlers/projects/spec/paths/projects.projectId.users.userId.yml'
/community-packages:
$ref: './handlers/community-packages/spec/paths/community-packages.yml'
/community-packages/{name}:

View File

@ -1016,6 +1016,7 @@ export class ProjectService {
const [members, count] = await this.projectRelationRepository.findAndCount({
where: { projectId },
relations: { user: true, role: true },
order: { createdAt: 'ASC', userId: 'ASC' },
skip: offset,
take: limit,
});

View File

@ -7,6 +7,7 @@ import {
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { Container } from '@n8n/di';
import type { MockInstance } from 'vitest';
@ -18,6 +19,7 @@ import {
createMemberWithApiKey,
createOwnerWithApiKey,
createMember,
createUserShell,
} from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
@ -615,7 +617,7 @@ describe('Projects in Public API', () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member1 = await createMember();
const member2 = await createMember();
const member2 = await createUserShell(GLOBAL_MEMBER_ROLE);
await linkUserToProject(member1, project, 'project:viewer');
await linkUserToProject(member2, project, 'project:editor');
@ -630,25 +632,23 @@ describe('Projects in Public API', () => {
* Assert
*/
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('nextCursor');
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(3); // owner (admin) + member1 + member2
expect(response.body.data).toHaveLength(3); // owner (admin) + member1 + member2
const memberIds = new Set(response.body.data.map((m: { id: string }) => m.id));
expect(memberIds).toContain(owner.id);
expect(memberIds).toContain(member1.id);
expect(memberIds).toContain(member2.id);
for (const row of response.body.data) {
expect(row).toHaveProperty('id');
expect(row).toHaveProperty('email');
expect(row).toHaveProperty('firstName');
expect(row).toHaveProperty('lastName');
expect(row).toHaveProperty('createdAt');
expect(row).toHaveProperty('updatedAt');
expect(row).toHaveProperty('role');
}
// A shell user has no names yet, so this row shows every field, null columns included
expect(response.body.data).toContainEqual({
id: member2.id,
email: member2.email,
firstName: null,
lastName: null,
createdAt: expect.any(String),
updatedAt: expect.any(String),
role: 'project:editor',
});
const adminRow = response.body.data.find((m: { id: string }) => m.id === owner.id);
expect(adminRow.role).toBe('project:admin');
@ -716,7 +716,7 @@ describe('Projects in Public API', () => {
.get(`/projects/${project.id}/users`);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
expect(response.body).toHaveProperty('message', 'Unauthorized');
});
it('if not licensed, should reject', async () => {
@ -762,6 +762,34 @@ describe('Projects in Public API', () => {
`Could not find project with ID "${project.id}"`,
);
});
it('if missing scope, should reject', async () => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey({ scopes: ['project:list'] });
const project = await createTeamProject('shared-project', owner);
const response = await testServer
.publicApiAgentFor(owner)
.get(`/projects/${project.id}/users`);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
it('should reject an invalid cursor', async () => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const response = await testServer
.publicApiAgentFor(owner)
.get(`/projects/${project.id}/users?cursor=not-a-cursor`);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message', 'An invalid cursor was provided');
});
});
describe('POST /projects/:id/users', () => {
@ -773,7 +801,7 @@ describe('Projects in Public API', () => {
.post(`/projects/${project.id}/users`);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
expect(response.body).toHaveProperty('message', 'Unauthorized');
});
it('if not licensed, should reject with a 403', async () => {
@ -861,6 +889,22 @@ describe('Projects in Public API', () => {
);
});
it('should reject with 400 if the body is empty', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const response = await testServer
.publicApiAgentFor(owner)
.post(`/projects/${project.id}/users`)
.send({});
expect(response.status).toBe(400);
expect(response.body).toHaveProperty(
'message',
"request/body must have required property 'relations'",
);
});
it('should reject if the relations have a role that do not exist', async () => {
const owner = await createOwnerWithApiKey();
const member = await createMember();
@ -940,6 +984,8 @@ describe('Projects in Public API', () => {
});
expect(response.status).toBe(201);
expect(response.text).toBe('');
expect(response.headers['content-type']).toBeUndefined();
expect(projectBefore.length).toEqual(1);
expect(projectBefore[0].userId).toEqual(owner.id);
@ -1027,7 +1073,7 @@ describe('Projects in Public API', () => {
.send({ role: 'project:viewer' });
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
expect(response.body).toHaveProperty('message', 'Unauthorized');
});
it('if not licensed, should reject with a 403', async () => {
@ -1179,7 +1225,7 @@ describe('Projects in Public API', () => {
.delete(`/projects/${project.id}/users/${member.id}`);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
expect(response.body).toHaveProperty('message', 'Unauthorized');
});
it('if not licensed, should reject with a 403', async () => {