feat: add ability to add users to projects via API

This commit is contained in:
Marc Littlemore 2024-12-20 16:42:55 +00:00
parent 73a4b9bcb1
commit cdfc40dc2d
No known key found for this signature in database
5 changed files with 264 additions and 1 deletions

View file

@ -14,6 +14,7 @@ type Update = ProjectRequest.Update;
type Delete = ProjectRequest.Delete;
type DeleteUser = ProjectRequest.DeleteUser;
type GetAll = PaginatedRequest;
type AddUsers = ProjectRequest.AddUsers;
export = {
createProject: [
@ -87,4 +88,43 @@ export = {
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();
},
],
};

View file

@ -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'

View file

@ -82,6 +82,8 @@ paths:
$ref: './handlers/projects/spec/paths/projects.yml'
/projects/{projectId}:
$ref: './handlers/projects/spec/paths/projects.projectId.yml'
/projects/{projectId}/users:
$ref: './handlers/projects/spec/paths/projects.projectId.users.yml'
/projects/{projectId}/users/{id}:
$ref: './handlers/projects/spec/paths/projects.projectId.users.id.yml'
components:

View file

@ -564,6 +564,11 @@ export declare namespace ProjectRequest {
>;
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
type DeleteUser = AuthenticatedRequest<{ projectId: string; id: string }, {}, {}, {}>;
type AddUsers = AuthenticatedRequest<
{ projectId: string },
{},
{ users: ProjectRelationPayload[] }
>;
}
// ----------------------------------

View file

@ -475,7 +475,7 @@ describe('Projects in Public API', () => {
expect(projectBefore[1].userId).toEqual(member.id);
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 () => {
@ -511,7 +511,174 @@ describe('Projects in Public API', () => {
expect(projectBefore[0].userId).toEqual(owner.id);
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(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".',
);
});
});
});