feat: Project users public endpoint (#25189)

This commit is contained in:
Stephen Wright
2026-02-04 09:16:46 +00:00
committed by GitHub
parent f45ba3f521
commit a5f84ec040
5 changed files with 300 additions and 1 deletions
@@ -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' }),
@@ -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();