mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-18 05:59:42 +02:00
281 lines
8.1 KiB
TypeScript
281 lines
8.1 KiB
TypeScript
import {
|
|
CreateProjectDto,
|
|
DeleteProjectDto,
|
|
UpdateProjectDto,
|
|
AddUsersToProjectDto,
|
|
ChangeUserRoleInProject,
|
|
ListProjectsQueryDto,
|
|
} from '@n8n/api-types';
|
|
import { AuthenticatedRequest } from '@n8n/db';
|
|
import {
|
|
Get,
|
|
Post,
|
|
GlobalScope,
|
|
RestController,
|
|
Licensed,
|
|
Patch,
|
|
ProjectScope,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
} from '@n8n/decorators';
|
|
import { combineScopes, getAuthPrincipalScopes } from '@n8n/permissions';
|
|
import { Response } from 'express';
|
|
|
|
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
|
|
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
|
|
import type { ProjectRequest } from '@/requests';
|
|
import {
|
|
ProjectService,
|
|
TeamProjectOverQuotaError,
|
|
UnlicensedProjectRoleError,
|
|
} from '@/services/project.service.ee';
|
|
|
|
@RestController('/projects')
|
|
export class ProjectController {
|
|
constructor(
|
|
private readonly projectsService: ProjectService,
|
|
private readonly provisioningService: ProvisioningService,
|
|
) {}
|
|
|
|
@Get('/')
|
|
async getAllProjects(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
@Query payload: ListProjectsQueryDto,
|
|
) {
|
|
const { projects, count } = await this.projectsService.getAccessibleProjectsAndCount(
|
|
req.user,
|
|
payload,
|
|
);
|
|
|
|
// When pagination params are provided, return { count, data } envelope
|
|
// with role and scopes enriched per project.
|
|
// Otherwise return a bare array for backward compatibility with existing callers.
|
|
if (payload.take !== undefined || payload.skip !== undefined) {
|
|
const enriched = await this.projectsService.addUserScopes(req.user, projects);
|
|
return res.json({ count, data: enriched });
|
|
}
|
|
return projects;
|
|
}
|
|
|
|
@Get('/count')
|
|
async getProjectCounts() {
|
|
return await this.projectsService.getProjectCounts();
|
|
}
|
|
|
|
// Lists projects a caller can pick as share targets, including peer
|
|
// personal projects so the workflow / credential share dropdowns can
|
|
// surface other users. Gated on `user:list` (the same boundary that
|
|
// `GET /rest/users` enforces) — restricted roles without that scope
|
|
// (e.g. chat-only users) cannot enumerate peer personal projects here.
|
|
@Get('/sharing-candidates')
|
|
@GlobalScope('user:list')
|
|
async getSharingCandidates(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
@Query payload: ListProjectsQueryDto,
|
|
) {
|
|
const { projects, count } = await this.projectsService.getShareableProjectsAndCount(
|
|
req.user,
|
|
payload,
|
|
);
|
|
const enriched = await this.projectsService.addUserScopes(req.user, projects);
|
|
return res.json({ count, data: enriched });
|
|
}
|
|
|
|
@Post('/')
|
|
@GlobalScope('project:create')
|
|
// Using admin as all plans that contain projects should allow admins at the very least
|
|
@Licensed('feat:projectRole:admin')
|
|
async createProject(req: AuthenticatedRequest, _res: Response, @Body payload: CreateProjectDto) {
|
|
try {
|
|
const project = await this.projectsService.createTeamProject(req.user, payload);
|
|
|
|
return {
|
|
...project,
|
|
role: 'project:admin',
|
|
scopes: await this.projectsService.getProjectScopesForUser(req.user, project.id),
|
|
};
|
|
} catch (e) {
|
|
if (e instanceof TeamProjectOverQuotaError) {
|
|
throw new BadRequestError(e.message);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Get('/my-projects')
|
|
async getMyProjects(
|
|
req: AuthenticatedRequest,
|
|
_res: Response,
|
|
): Promise<ProjectRequest.GetMyProjectsResponse> {
|
|
return await this.projectsService.getMyProjects(req.user);
|
|
}
|
|
|
|
@Get('/personal')
|
|
async getPersonalProject(req: AuthenticatedRequest) {
|
|
const project = await this.projectsService.getPersonalProject(req.user);
|
|
if (!project) {
|
|
throw new NotFoundError('Could not find a personal project for this user');
|
|
}
|
|
|
|
const scopes = await this.projectsService.getProjectScopesForUser(req.user, project.id);
|
|
return {
|
|
...project,
|
|
scopes,
|
|
// Personal projects have a single owner and are never subject to managed team roles.
|
|
rolesManaged: false,
|
|
};
|
|
}
|
|
|
|
@Get('/:projectId')
|
|
@ProjectScope('project:read')
|
|
async getProject(
|
|
req: AuthenticatedRequest,
|
|
_res: Response,
|
|
@Param('projectId') projectId: string,
|
|
): Promise<ProjectRequest.ProjectWithRelations> {
|
|
const project = await this.projectsService.getProject(projectId);
|
|
const { id, name, icon, type, description, customTelemetryTags } = project;
|
|
const [relations, rolesManaged, implicitMembers] = await Promise.all([
|
|
this.projectsService.getProjectRelations(projectId),
|
|
this.provisioningService.isProjectRoleManaged(),
|
|
this.projectsService.getImplicitProjectMembers(project),
|
|
]);
|
|
const myRelation = relations.find((r) => r.userId === req.user.id);
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
icon,
|
|
type,
|
|
description,
|
|
customTelemetryTags,
|
|
creatorId: project.creatorId ?? null,
|
|
relations: relations.map((r) => ({
|
|
id: r.user.id,
|
|
email: r.user.email,
|
|
firstName: r.user.firstName,
|
|
lastName: r.user.lastName,
|
|
role: r.role.slug,
|
|
})),
|
|
implicitMembers: implicitMembers.map((user) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
globalRole: { slug: user.role.slug, displayName: user.role.displayName },
|
|
})),
|
|
scopes: [
|
|
...combineScopes({
|
|
global: getAuthPrincipalScopes(req.user),
|
|
...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}),
|
|
}),
|
|
],
|
|
rolesManaged,
|
|
};
|
|
}
|
|
|
|
@Patch('/:projectId')
|
|
@ProjectScope('project:update')
|
|
async updateProject(
|
|
req: AuthenticatedRequest,
|
|
_res: Response,
|
|
@Body payload: UpdateProjectDto,
|
|
@Param('projectId') projectId: string,
|
|
) {
|
|
await this.projectsService.updateProject(req.user, projectId, payload);
|
|
}
|
|
|
|
/** Throws when project roles are provisioned automatically, so manual membership changes are disallowed. */
|
|
private async assertProjectRolesNotManaged() {
|
|
if (await this.provisioningService.isProjectRoleManaged()) {
|
|
throw new ForbiddenError(
|
|
'Project roles are managed automatically and cannot be changed manually',
|
|
);
|
|
}
|
|
}
|
|
|
|
@Post('/:projectId/users')
|
|
@ProjectScope('project:manageMembers')
|
|
async addProjectUsers(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
@Param('projectId') projectId: string,
|
|
@Body payload: AddUsersToProjectDto,
|
|
) {
|
|
await this.assertProjectRolesNotManaged();
|
|
try {
|
|
const { added, conflicts } = await this.projectsService.addUsersWithConflictSemantics(
|
|
req.user,
|
|
projectId,
|
|
payload.relations,
|
|
);
|
|
|
|
// Response semantics:
|
|
// - If at least one user was added, return 201. When there are also conflicts, include them in the body.
|
|
// - If no users were added but conflicts exist, return 409 with conflicts.
|
|
if (added.length > 0) {
|
|
return conflicts.length > 0 ? res.status(201).json({ conflicts }) : res.status(201).send();
|
|
}
|
|
if (conflicts.length > 0) return res.status(409).json({ conflicts });
|
|
return res.status(200).send();
|
|
} catch (e) {
|
|
if (e instanceof UnlicensedProjectRoleError) {
|
|
throw new BadRequestError(e.message);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Patch('/:projectId/users/:userId')
|
|
@ProjectScope('project:manageMembers')
|
|
async changeProjectUserRole(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
@Param('projectId') projectId: string,
|
|
@Param('userId') userId: string,
|
|
@Body body: ChangeUserRoleInProject,
|
|
) {
|
|
await this.assertProjectRolesNotManaged();
|
|
|
|
try {
|
|
await this.projectsService.changeUserRoleInProject(req.user, projectId, userId, body.role);
|
|
return res.status(204).send();
|
|
} catch (e) {
|
|
if (e instanceof UnlicensedProjectRoleError) {
|
|
throw new BadRequestError(e.message);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
@Delete('/:projectId/users/:userId')
|
|
@ProjectScope('project:manageMembers')
|
|
async deleteProjectUser(
|
|
req: AuthenticatedRequest,
|
|
res: Response,
|
|
@Param('projectId') projectId: string,
|
|
@Param('userId') userId: string,
|
|
) {
|
|
await this.assertProjectRolesNotManaged();
|
|
await this.projectsService.deleteUserFromProject(req.user, projectId, userId);
|
|
return res.status(204).send();
|
|
}
|
|
|
|
@Delete('/:projectId')
|
|
@ProjectScope('project:delete')
|
|
async deleteProject(
|
|
req: AuthenticatedRequest,
|
|
_res: Response,
|
|
@Query query: DeleteProjectDto,
|
|
@Param('projectId') projectId: string,
|
|
) {
|
|
await this.projectsService.deleteProject(req.user, projectId, {
|
|
migrateToProject: query.transferId,
|
|
});
|
|
}
|
|
}
|