mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 14:56:50 +02:00
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:
parent
999b541764
commit
697b538c95
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}) {}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -137,6 +137,7 @@ export {
|
|||
promotionProviderIdParamSchema,
|
||||
roleMappingRuleIdParamSchema,
|
||||
roleSlugParamSchema,
|
||||
userIdParamSchema,
|
||||
workflowIdParamSchema,
|
||||
workflowVersionIdParamSchema,
|
||||
} from './schemas/public-api-path-params.schema';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
name: projectId
|
||||
in: path
|
||||
description: The ID of the project.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
readOnly: true
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
readOnly: true
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user