mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: add ability to add users to projects via API
This commit is contained in:
parent
73a4b9bcb1
commit
cdfc40dc2d
|
@ -14,6 +14,7 @@ type Update = ProjectRequest.Update;
|
||||||
type Delete = ProjectRequest.Delete;
|
type Delete = ProjectRequest.Delete;
|
||||||
type DeleteUser = ProjectRequest.DeleteUser;
|
type DeleteUser = ProjectRequest.DeleteUser;
|
||||||
type GetAll = PaginatedRequest;
|
type GetAll = PaginatedRequest;
|
||||||
|
type AddUsers = ProjectRequest.AddUsers;
|
||||||
|
|
||||||
export = {
|
export = {
|
||||||
createProject: [
|
createProject: [
|
||||||
|
@ -87,4 +88,43 @@ export = {
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
addUsersToProject: [
|
||||||
|
isLicensed('feat:projectRole:admin'),
|
||||||
|
globalScope('project:update'),
|
||||||
|
async (req: AddUsers, res: Response) => {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
const { users } = req.body;
|
||||||
|
|
||||||
|
const project = await Container.get(ProjectRepository).findOne({
|
||||||
|
where: { id: projectId },
|
||||||
|
relations: { projectRelations: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).send({ message: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUsers = project.projectRelations.map((relation) => ({
|
||||||
|
userId: relation.userId,
|
||||||
|
role: relation.role,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// TODO:
|
||||||
|
// - What happens when the user is already in the project?
|
||||||
|
// - What happens when the user is not found on the instance?
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Container.get(ProjectController).syncProjectRelations(projectId, [
|
||||||
|
...existingUsers,
|
||||||
|
...users,
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.send({ message: error instanceof Error ? error.message : 'Bad request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(201).send();
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
post:
|
||||||
|
x-eov-operation-id: addUsersToProject
|
||||||
|
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 from 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:
|
||||||
|
users:
|
||||||
|
type: array
|
||||||
|
description: A list of users 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:
|
||||||
|
- users
|
||||||
|
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'
|
|
@ -82,6 +82,8 @@ paths:
|
||||||
$ref: './handlers/projects/spec/paths/projects.yml'
|
$ref: './handlers/projects/spec/paths/projects.yml'
|
||||||
/projects/{projectId}:
|
/projects/{projectId}:
|
||||||
$ref: './handlers/projects/spec/paths/projects.projectId.yml'
|
$ref: './handlers/projects/spec/paths/projects.projectId.yml'
|
||||||
|
/projects/{projectId}/users:
|
||||||
|
$ref: './handlers/projects/spec/paths/projects.projectId.users.yml'
|
||||||
/projects/{projectId}/users/{id}:
|
/projects/{projectId}/users/{id}:
|
||||||
$ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml'
|
$ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml'
|
||||||
components:
|
components:
|
||||||
|
|
|
@ -564,6 +564,11 @@ export declare namespace ProjectRequest {
|
||||||
>;
|
>;
|
||||||
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
|
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
|
||||||
type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>;
|
type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>;
|
||||||
|
type AddUsers = AuthenticatedRequest<
|
||||||
|
{ projectId: string },
|
||||||
|
{},
|
||||||
|
{ users: ProjectRelationPayload[] }
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -475,7 +475,7 @@ describe('Projects in Public API', () => {
|
||||||
expect(projectBefore[1].userId).toEqual(member.id);
|
expect(projectBefore[1].userId).toEqual(member.id);
|
||||||
|
|
||||||
expect(projectAfter.length).toEqual(1);
|
expect(projectAfter.length).toEqual(1);
|
||||||
expect(projectBefore[0].userId).toEqual(owner.id);
|
expect(projectAfter[0].userId).toEqual(owner.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject with 404 if no project found', async () => {
|
it('should reject with 404 if no project found', async () => {
|
||||||
|
@ -511,7 +511,174 @@ describe('Projects in Public API', () => {
|
||||||
expect(projectBefore[0].userId).toEqual(owner.id);
|
expect(projectBefore[0].userId).toEqual(owner.id);
|
||||||
|
|
||||||
expect(projectAfter.length).toEqual(1);
|
expect(projectAfter.length).toEqual(1);
|
||||||
|
expect(projectAfter[0].userId).toEqual(owner.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /projects/:id/users', () => {
|
||||||
|
it('if not authenticated, should reject with 401', async () => {
|
||||||
|
const project = await createTeamProject();
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentWithoutApiKey()
|
||||||
|
.post(`/projects/${project.id}/users`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if not licensed, should reject with a 403', async () => {
|
||||||
|
const owner = await createOwnerWithApiKey();
|
||||||
|
const project = await createTeamProject();
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
userId: member.id,
|
||||||
|
role: 'project:viewer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.post(`/projects/${project.id}/users`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
new FeatureNotLicensedError('feat:projectRole:admin').message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('if missing scope, should reject with 403', async () => {
|
||||||
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
|
const member = await createMemberWithApiKey();
|
||||||
|
const project = await createTeamProject();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
userId: member.id,
|
||||||
|
role: 'project:viewer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(member)
|
||||||
|
.post(`/projects/${project.id}/users`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Forbidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when user has correct license', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||||
|
testServer.license.enable('feat:projectRole:admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject with 404 if no project found', async () => {
|
||||||
|
const owner = await createOwnerWithApiKey();
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
userId: member.id,
|
||||||
|
role: 'project:viewer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.post('/projects/123456/users/')
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body).toHaveProperty('message', 'Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add expected users to project', async () => {
|
||||||
|
testServer.license.enable('feat:projectRole:viewer');
|
||||||
|
testServer.license.enable('feat:projectRole:editor');
|
||||||
|
const owner = await createOwnerWithApiKey();
|
||||||
|
const project = await createTeamProject('shared-project', owner);
|
||||||
|
const member = await createMember();
|
||||||
|
const member2 = await createMember();
|
||||||
|
const projectBefore = await getAllProjectRelations({
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
userId: member.id,
|
||||||
|
role: 'project:viewer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: member2.id,
|
||||||
|
role: 'project:editor',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.post(`/projects/${project.id}/users`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
const projectAfter = await getAllProjectRelations({
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(projectBefore.length).toEqual(1);
|
||||||
expect(projectBefore[0].userId).toEqual(owner.id);
|
expect(projectBefore[0].userId).toEqual(owner.id);
|
||||||
|
|
||||||
|
expect(projectAfter.length).toEqual(3);
|
||||||
|
expect(projectAfter[0]).toEqual(
|
||||||
|
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
|
||||||
|
);
|
||||||
|
expect(projectAfter[1]).toEqual(
|
||||||
|
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
|
||||||
|
);
|
||||||
|
expect(projectAfter[2]).toEqual(
|
||||||
|
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject with 400 if license does not include user role', async () => {
|
||||||
|
const owner = await createOwnerWithApiKey();
|
||||||
|
const project = await createTeamProject('shared-project', owner);
|
||||||
|
const member = await createMember();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
userId: member.id,
|
||||||
|
role: 'project:viewer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await testServer
|
||||||
|
.publicApiAgentFor(owner)
|
||||||
|
.post(`/projects/${project.id}/users`)
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toHaveProperty(
|
||||||
|
'message',
|
||||||
|
'Your instance is not licensed to use role "project:viewer".',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue