mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat: Project users public endpoint (#25189)
This commit is contained in:
@@ -6,11 +6,13 @@ import {
|
||||
UpdateProjectWithRelationsDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { ProjectRepository } from '@n8n/db';
|
||||
import { ProjectRelationRepository, ProjectRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import pick from 'lodash/pick';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ProjectController } from '@/controllers/project.controller';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { ResponseError } from '@/errors/response-errors/abstract/response.error';
|
||||
import type { PaginatedRequest } from '@/public-api/types';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
@@ -98,6 +100,61 @@ export = {
|
||||
});
|
||||
},
|
||||
],
|
||||
getProjectUsers: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }),
|
||||
validCursor,
|
||||
async (req: AuthenticatedRequest<{ projectId: string }> & GetAll, res: Response) => {
|
||||
const { projectId } = req.params;
|
||||
const offset = Number(req.query.offset) || 0;
|
||||
const limit = Number(req.query.limit) || 100;
|
||||
|
||||
try {
|
||||
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 projectRelationRepository = Container.get(ProjectRelationRepository);
|
||||
const [relations, count] = await projectRelationRepository.findAndCount({
|
||||
where: { projectId },
|
||||
relations: { user: true, role: true },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const memberFields = [
|
||||
'id',
|
||||
'email',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
] as const;
|
||||
const data = relations.map((relation) => ({
|
||||
...pick(relation.user, memberFields),
|
||||
role: relation.role?.slug ?? null,
|
||||
}));
|
||||
|
||||
return res.json({
|
||||
data,
|
||||
nextCursor: encodeNextCursor({
|
||||
offset,
|
||||
limit,
|
||||
numberOfTotalRecords: count,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
return res.status(error.httpStatusCode).json({ message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
],
|
||||
addUsersToProject: [
|
||||
isLicensed('feat:projectRole:admin'),
|
||||
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
|
||||
|
||||
+29
@@ -1,3 +1,32 @@
|
||||
get:
|
||||
x-eov-operation-id: getProjectUsers
|
||||
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-eov-operation-handler: v1/handlers/projects/projects.handler
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -407,6 +407,167 @@ describe('Projects in Public API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /projects/:id/users', () => {
|
||||
it('if licensed, should return project members with pagination', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
testServer.license.enable('feat:projectRole:viewer');
|
||||
testServer.license.enable('feat:projectRole:editor');
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member1 = await createMember();
|
||||
const member2 = await createMember();
|
||||
await linkUserToProject(member1, project, 'project:viewer');
|
||||
await linkUserToProject(member2, project, 'project:editor');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.get(`/projects/${project.id}/users`);
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const adminRow = response.body.data.find((m: { id: string }) => m.id === owner.id);
|
||||
expect(adminRow.role).toBe('project:admin');
|
||||
const viewerRow = response.body.data.find((m: { id: string }) => m.id === member1.id);
|
||||
expect(viewerRow.role).toBe('project:viewer');
|
||||
const editorRow = response.body.data.find((m: { id: string }) => m.id === member2.id);
|
||||
expect(editorRow.role).toBe('project:editor');
|
||||
});
|
||||
|
||||
it('if licensed, should respect limit and cursor for pagination', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
testServer.license.enable('feat:projectRole:viewer');
|
||||
testServer.license.enable('feat:projectRole:editor');
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject('shared-project', owner);
|
||||
const member1 = await createMember();
|
||||
const member2 = await createMember();
|
||||
await linkUserToProject(member1, project, 'project:viewer');
|
||||
await linkUserToProject(member2, project, 'project:editor');
|
||||
|
||||
/**
|
||||
* Act – first page
|
||||
*/
|
||||
const first = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.get(`/projects/${project.id}/users`)
|
||||
.query({ limit: 2 });
|
||||
|
||||
/**
|
||||
* Assert first page
|
||||
*/
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.data.length).toBe(2);
|
||||
expect(first.body.nextCursor).toBeDefined();
|
||||
|
||||
/**
|
||||
* Act – second page
|
||||
*/
|
||||
const second = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.get(`/projects/${project.id}/users`)
|
||||
.query({ limit: 2, cursor: first.body.nextCursor });
|
||||
|
||||
/**
|
||||
* Assert second page
|
||||
*/
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body.data.length).toBe(1);
|
||||
const allIds = [
|
||||
...first.body.data.map((m: { id: string }) => m.id),
|
||||
...second.body.data.map((m: { id: string }) => m.id),
|
||||
];
|
||||
expect(new Set(allIds).size).toBe(3);
|
||||
});
|
||||
|
||||
it('if not authenticated, should reject', async () => {
|
||||
const project = await createTeamProject();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentWithoutApiKey()
|
||||
.get(`/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', async () => {
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const project = await createTeamProject();
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(owner)
|
||||
.get(`/projects/${project.id}/users`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
new FeatureNotLicensedError('feat:projectRole:admin').message,
|
||||
);
|
||||
});
|
||||
|
||||
it('if project not found, should reject with 404', async () => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
const owner = await createOwnerWithApiKey();
|
||||
|
||||
const response = await testServer.publicApiAgentFor(owner).get('/projects/123456/users');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('message', 'Could not find project with ID "123456"');
|
||||
});
|
||||
|
||||
it('if user has no access to project, should reject with 404', async () => {
|
||||
testServer.license.setQuota('quota:maxTeamProjects', -1);
|
||||
testServer.license.enable('feat:projectRole:admin');
|
||||
const owner = await createOwnerWithApiKey();
|
||||
const member = await createMemberWithApiKey({ scopes: ['user:list'] });
|
||||
const project = await createTeamProject('other-owner-project', owner);
|
||||
|
||||
const response = await testServer
|
||||
.publicApiAgentFor(member)
|
||||
.get(`/projects/${project.id}/users`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty(
|
||||
'message',
|
||||
`Could not find project with ID "${project.id}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /projects/:id/users', () => {
|
||||
it('if not authenticated, should reject with 401', async () => {
|
||||
const project = await createTeamProject();
|
||||
|
||||
Reference in New Issue
Block a user