feat: Make project member updates immediate (#19837)

This commit is contained in:
Csaba Tuncsik 2025-09-26 16:03:17 +02:00 committed by GitHub
parent 0e9464a32c
commit b59f97631d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 854 additions and 316 deletions

View File

@ -27,7 +27,7 @@ export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { CreateProjectDto } from './project/create-project.dto';
export { UpdateProjectDto } from './project/update-project.dto';
export { UpdateProjectDto, UpdateProjectWithRelationsDto } from './project/update-project.dto';
export { DeleteProjectDto } from './project/delete-project.dto';
export { AddUsersToProjectDto } from './project/add-users-to-project.dto';
export { ChangeUserRoleInProject } from './project/change-user-role-in-project.dto';

View File

@ -1,6 +1,6 @@
import { UpdateProjectDto } from '../update-project.dto';
import { UpdateProjectWithRelationsDto } from '../update-project.dto';
describe('UpdateProjectDto', () => {
describe('UpdateProjectWithRelationsDto', () => {
describe('Valid requests', () => {
test.each([
{
@ -70,7 +70,7 @@ describe('UpdateProjectDto', () => {
},
},
])('should pass validation for $name', ({ request }) => {
const result = UpdateProjectDto.safeParse(request);
const result = UpdateProjectWithRelationsDto.safeParse(request);
expect(result.success).toBe(true);
});
});
@ -137,7 +137,7 @@ describe('UpdateProjectDto', () => {
expectedErrorPath: ['description'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateProjectDto.safeParse(request);
const result = UpdateProjectWithRelationsDto.safeParse(request);
expect(result.success).toBe(false);

View File

@ -8,9 +8,15 @@ import {
projectRelationSchema,
} from '../../schemas/project.schema';
export class UpdateProjectDto extends Z.class({
const updateProjectShape = {
name: projectNameSchema.optional(),
icon: projectIconSchema.optional(),
description: projectDescriptionSchema.optional(),
};
export class UpdateProjectDto extends Z.class(updateProjectShape) {}
export class UpdateProjectWithRelationsDto extends Z.class({
...updateProjectShape,
relations: z.array(projectRelationSchema).optional(),
}) {}

View File

@ -1,4 +1,4 @@
export type * from './types.ee';
export * from './types.ee';
export * from './constants.ee';
export * from './roles/scopes/global-scopes.ee';

View File

@ -12,6 +12,7 @@ import type {
workflowSharingRoleSchema,
assignableProjectRoleSchema,
} from './schemas.ee';
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
import { ALL_API_KEY_SCOPES } from './scope-information';
export type ScopeInformation = {
@ -62,6 +63,18 @@ export type TeamProjectRole = z.infer<typeof teamRoleSchema>;
export type ProjectRole = z.infer<typeof systemProjectRoleSchema>;
export type AssignableProjectRole = z.infer<typeof assignableProjectRoleSchema>;
/**
* Type guard for assignable project role slugs.
*
* Custom project roles are supported. We consider any slug that:
* - starts with the `project:` prefix, and
* - is not the personal owner role
* to be an assignable project role.
*/
export function isAssignableProjectRoleSlug(slug: string): slug is AssignableProjectRole {
return slug.startsWith('project:') && slug !== PROJECT_OWNER_ROLE_SLUG;
}
/** Union of all possible role types in the system */
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;

View File

@ -0,0 +1,188 @@
import type { AuthenticatedRequest, ProjectRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
import type { EventService } from '@/events/event.service';
import type { Response } from 'express';
import { ProjectController } from '@/controllers/project.controller';
import type { ProjectService } from '@/services/project.service.ee';
import type { UserManagementMailer } from '@/user-management/email';
describe('ProjectController (members endpoints)', () => {
const eventService = mock<EventService>();
const projectsService = mock<ProjectService>();
const projectRepository = mock<ProjectRepository>();
const userManagementMailer = mock<UserManagementMailer>();
const controller = new ProjectController(
projectsService as unknown as ProjectService,
projectRepository as unknown as ProjectRepository,
eventService as unknown as EventService,
userManagementMailer as unknown as UserManagementMailer,
);
const makeRes = () => {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as unknown as Response;
return res;
};
const req: AuthenticatedRequest = {
user: { id: 'actor-user', role: { slug: 'global:owner' } } as any,
} as AuthenticatedRequest;
beforeEach(() => {
jest.resetAllMocks();
});
it('emits team-project-updated with full members list on addProjectUsers', async () => {
// Arrange
const projectId = 'p1';
const payload = { relations: [{ userId: 'u2', role: 'project:viewer' as const }] };
(projectsService.addUsersWithConflictSemantics as jest.Mock).mockResolvedValue({
project: { id: projectId, name: 'Project' },
added: payload.relations,
conflicts: [],
});
(projectsService.getProjectRelations as jest.Mock).mockResolvedValue([
{ userId: 'u1', role: { slug: 'project:admin' } },
{ userId: 'u2', role: { slug: 'project:viewer' } },
]);
const res = makeRes();
// Act
await controller.addProjectUsers(req, res, projectId, payload as any);
// Assert
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
members: [
{ userId: 'u1', role: 'project:admin' },
{ userId: 'u2', role: 'project:viewer' },
],
projectId,
});
// Verify mailer called for new sharees
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({
sharer: req.user,
newSharees: payload.relations,
project: { id: projectId, name: 'Project' },
});
});
it('emits team-project-updated on changeProjectUserRole and returns 204', async () => {
// Arrange
const projectId = 'p2';
(projectsService.getProjectRelations as jest.Mock).mockResolvedValue([
{ userId: 'u1', role: { slug: 'project:admin' } },
{ userId: 'u2', role: { slug: 'project:editor' } },
]);
const res = makeRes();
// Act
await controller.changeProjectUserRole(req, res, projectId, 'u2', {
role: 'project:editor',
} as any);
// Assert
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
members: [
{ userId: 'u1', role: 'project:admin' },
{ userId: 'u2', role: 'project:editor' },
],
projectId,
});
expect(res.status).toHaveBeenCalledWith(204);
});
it('emits team-project-updated on deleteProjectUser and returns 204', async () => {
// Arrange
const projectId = 'p3';
(projectsService.getProjectRelations as jest.Mock).mockResolvedValue([
{ userId: 'u1', role: { slug: 'project:admin' } },
{ userId: 'u3', role: { slug: 'project:viewer' } },
]);
const res = makeRes();
// Act
await controller.deleteProjectUser(req, res, projectId, 'u2');
// Assert
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
members: [
{ userId: 'u1', role: 'project:admin' },
{ userId: 'u3', role: 'project:viewer' },
],
projectId,
});
expect(res.status).toHaveBeenCalledWith(204);
});
it('returns 201 with conflicts body when some users added and some conflicted', async () => {
// Arrange
const projectId = 'p4';
const added = [{ userId: 'u4', role: 'project:viewer' as const }];
const conflicts = [
{
userId: 'u5',
currentRole: 'project:viewer' as const,
requestedRole: 'project:editor' as const,
},
];
(projectsService.addUsersWithConflictSemantics as jest.Mock).mockResolvedValue({
project: { id: projectId, name: 'Project' },
added,
conflicts,
});
(projectsService.getProjectRelations as jest.Mock).mockResolvedValue([
{ userId: 'u1', role: { slug: 'project:admin' } },
{ userId: 'u4', role: { slug: 'project:viewer' } },
{ userId: 'u5', role: { slug: 'project:viewer' } },
]);
const res = makeRes();
// Act
await controller.addProjectUsers(req, res, projectId, {
relations: [...added, { userId: 'u5', role: 'project:editor' }],
} as any);
// Assert: 201 with conflicts body
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({ conflicts });
// Mailer is called for newly added sharees
expect(userManagementMailer.notifyProjectShared).toHaveBeenCalledWith({
sharer: req.user,
newSharees: added,
project: { id: projectId, name: 'Project' },
});
// Telemetry event has full members list
expect(eventService.emit).toHaveBeenCalledWith('team-project-updated', {
userId: 'actor-user',
role: 'global:owner',
members: [
{ userId: 'u1', role: 'project:admin' },
{ userId: 'u4', role: 'project:viewer' },
{ userId: 'u5', role: 'project:viewer' },
],
projectId,
});
});
});

View File

@ -1,4 +1,10 @@
import { CreateProjectDto, DeleteProjectDto, UpdateProjectDto } from '@n8n/api-types';
import {
CreateProjectDto,
DeleteProjectDto,
UpdateProjectDto,
AddUsersToProjectDto,
ChangeUserRoleInProject,
} from '@n8n/api-types';
import type { Project } from '@n8n/db';
import { AuthenticatedRequest, ProjectRepository } from '@n8n/db';
import {
@ -210,44 +216,106 @@ export class ProjectController {
@Patch('/:projectId')
@ProjectScope('project:update')
async updateProject(
req: AuthenticatedRequest,
_req: AuthenticatedRequest,
_res: Response,
@Body payload: UpdateProjectDto,
@Param('projectId') projectId: string,
) {
const { name, icon, relations, description } = payload;
if (name || icon || description) {
await this.projectsService.updateProject(projectId, { name, icon, description });
}
if (relations) {
try {
const { project, newRelations } = await this.projectsService.syncProjectRelations(
projectId,
relations,
);
await this.projectsService.updateProject(projectId, payload);
}
// Send email notifications to new sharees
@Post('/:projectId/users')
@ProjectScope('project:update')
async addProjectUsers(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Body payload: AddUsersToProjectDto,
) {
try {
const { added, conflicts, project } =
await this.projectsService.addUsersWithConflictSemantics(projectId, payload.relations);
if (added.length > 0) {
await this.userManagementMailer.notifyProjectShared({
sharer: req.user,
newSharees: newRelations,
newSharees: added,
project: { id: project.id, name: project.name },
});
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
}
throw e;
}
const relations = await this.projectsService.getProjectRelations(projectId);
this.eventService.emit('team-project-updated', {
userId: req.user.id,
role: req.user.role.slug,
members: relations,
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
projectId,
});
// 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:update')
async changeProjectUserRole(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body body: ChangeUserRoleInProject,
) {
try {
await this.projectsService.changeUserRoleInProject(projectId, userId, body.role);
await this.projectsService.clearCredentialCanUseExternalSecretsCache(projectId);
const relations = await this.projectsService.getProjectRelations(projectId);
this.eventService.emit('team-project-updated', {
userId: req.user.id,
role: req.user.role.slug,
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
projectId,
});
return res.status(204).send();
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
}
throw e;
}
}
@Delete('/:projectId/users/:userId')
@ProjectScope('project:update')
async deleteProjectUser(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Param('userId') userId: string,
) {
await this.projectsService.deleteUserFromProject(projectId, userId);
await this.projectsService.clearCredentialCanUseExternalSecretsCache(projectId);
const relations = await this.projectsService.getProjectRelations(projectId);
this.eventService.emit('team-project-updated', {
userId: req.user.id,
role: req.user.role.slug,
members: relations.map((r) => ({ userId: r.userId, role: r.role.slug })),
projectId,
});
return res.status(204).send();
}
@Delete('/:projectId')
@ProjectScope('project:delete')
async deleteProject(

View File

@ -3,7 +3,7 @@ import {
ChangeUserRoleInProject,
CreateProjectDto,
DeleteProjectDto,
UpdateProjectDto,
UpdateProjectWithRelationsDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import { ProjectRepository } from '@n8n/db';
@ -42,7 +42,7 @@ export = {
isLicensed('feat:projectRole:admin'),
apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:update' }),
async (req: AuthenticatedRequest<{ projectId: string }>, res: Response) => {
const payload = UpdateProjectDto.safeParse(req.body);
const payload = UpdateProjectWithRelationsDto.safeParse(req.body);
if (payload.error) {
return res.status(400).json(payload.error.errors[0]);
}

View File

@ -15,10 +15,10 @@ import { Container, Service } from '@n8n/di';
import {
hasGlobalScope,
type Scope,
type ProjectRole,
AssignableProjectRole,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_ADMIN_ROLE_SLUG,
isAssignableProjectRoleSlug,
} from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
@ -42,7 +42,7 @@ export class TeamProjectOverQuotaError extends UserError {
}
export class UnlicensedProjectRoleError extends UserError {
constructor(role: ProjectRole | AssignableProjectRole) {
constructor(role: AssignableProjectRole) {
super(`Your instance is not licensed to use role "${role}".`);
}
}
@ -353,6 +353,68 @@ export class ProjectService {
);
}
/**
* Add users with conflict semantics:
* - Adds users that are not already members
* - No-ops for users already in the project with the same role
* - Reports conflicts for users already in the project with a different role (no change)
*/
async addUsersWithConflictSemantics(
projectId: string,
relations: Array<{ userId: string; role: AssignableProjectRole }>,
): Promise<{
project: Project;
added: Array<{ userId: string; role: AssignableProjectRole }>;
conflicts: Array<{
userId: string;
currentRole: AssignableProjectRole;
requestedRole: AssignableProjectRole;
}>;
}> {
const project = await this.getTeamProjectWithRelations(projectId);
this.checkRolesLicensed(project, relations);
// Validate roles exist
await this.roleService.checkRolesExist(
relations.map((r) => r.role),
'project',
);
const existingByUserId = new Map(project.projectRelations.map((r) => [r.userId, r]));
const added: Array<{ userId: string; role: AssignableProjectRole }> = [];
const conflicts: Array<{
userId: string;
currentRole: AssignableProjectRole;
requestedRole: AssignableProjectRole;
}> = [];
for (const rel of relations) {
const existing = existingByUserId.get(rel.userId);
if (!existing) continue; // will be inserted below
const current = existing.role?.slug;
if (current && current !== rel.role && isAssignableProjectRoleSlug(current)) {
conflicts.push({ userId: rel.userId, currentRole: current, requestedRole: rel.role });
}
}
// Insert only non-existing users
const toInsert = relations.filter((rel) => !existingByUserId.has(rel.userId));
if (toInsert.length > 0) {
// Use insert to avoid accidental upsert of different role
await this.projectRelationRepository.insert(
toInsert.map((v) => ({
projectId: project.id,
userId: v.userId,
role: { slug: v.role },
})),
);
added.push(...toInsert);
}
await this.clearCredentialCanUseExternalSecretsCache(projectId);
return { project, added, conflicts };
}
private async getTeamProjectWithRelations(projectId: string) {
const project = await this.projectRepository.findOne({
where: { id: projectId, type: 'team' },
@ -411,6 +473,13 @@ export class ProjectService {
throw new ProjectNotFoundError(projectId);
}
// License check: only allow change to roles that are licensed
const currentRelation = project.projectRelations.find((r) => r.userId === userId);
const currentRole = currentRelation?.role?.slug;
if (currentRole !== role && !this.roleService.isRoleLicensed(role)) {
throw new UnlicensedProjectRoleError(role);
}
await this.projectRelationRepository.update({ projectId, userId }, { role: { slug: role } });
}

View File

@ -143,6 +143,97 @@ describe('GET /projects/', () => {
});
});
describe('Project members endpoints', () => {
test('POST /projects/:projectId/users adds a member and emits telemetry', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:viewer' }] });
expect(res.status).toBe(201);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test('POST /projects/:projectId/users returns 409 for existing member with different role', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
// First add as viewer
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:editor' }] });
expect(res.status).toBe(409);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test('POST /projects/:projectId/users returns 200 when member already exists with same role (no-op)', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
// First add as viewer
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:viewer' }] });
expect(res.status).toBe(200);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test("PATCH /projects/:projectId/users/:userId changes a member's role", async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' });
expect(res.status).toBe(204);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:editor')).toBe(
true,
);
});
test('DELETE /projects/:projectId/users/:userId removes a member', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent.delete(`/projects/${project.id}/users/${member.id}`);
expect(res.status).toBe(204);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id)).toBe(false);
});
});
describe('GET /projects/count', () => {
test('should return correct number of projects', async () => {
const [firstUser] = await Promise.all([
@ -576,10 +667,9 @@ describe('PATCH /projects/:projectId', () => {
const memberAgent = testServer.authAgentFor(testUser1);
const deleteSpy = jest.spyOn(Container.get(CacheService), 'deleteMany');
const resp = await memberAgent.patch(`/projects/${teamProject1.id}`).send({
name: teamProject1.name,
// Add two members to teamProject1
const addResp = await memberAgent.post(`/projects/${teamProject1.id}/users`).send({
relations: [
{ userId: testUser1.id, role: 'project:admin' },
{ userId: testUser3.id, role: 'project:editor' },
{ userId: ownerUser.id, role: 'project:viewer' },
] as Array<{
@ -587,8 +677,9 @@ describe('PATCH /projects/:projectId', () => {
role: ProjectRole;
}>,
});
expect(resp.status).toBe(200);
expect(addResp.status).toBe(201);
// External secrets cache must be cleared for credentials owned by teamProject1
expect(deleteSpy).toBeCalledWith([`credential-can-use-secrets:${credential1.id}`]);
deleteSpy.mockClear();
@ -687,13 +778,9 @@ describe('PATCH /projects/:projectId', () => {
await testServer
.authAgentFor(projectAdmin)
.patch(`/projects/${teamProject.id}`)
.post(`/projects/${teamProject.id}/users`)
.send({
name: teamProject.name,
relations: [
{ userId: projectAdmin.id, role: 'project:admin' },
{ userId: userToBeInvited.id, role },
] as Array<{
relations: [{ userId: userToBeInvited.id, role }] as Array<{
userId: string;
role: ProjectRole;
}>,
@ -727,17 +814,9 @@ describe('PATCH /projects/:projectId', () => {
const memberAgent = testServer.authAgentFor(testUser2);
const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({
name: teamProject.name,
relations: [
{ userId: testUser2.id, role: 'project:admin' },
{ userId: testUser1.id, role: 'project:editor' },
{ userId: testUser3.id, role: 'project:editor' },
] as Array<{
userId: string;
role: ProjectRole;
}>,
});
const resp = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser1.id}`)
.send({ role: 'project:editor' });
expect(resp.status).toBe(400);
const tpRelations = await getProjectRelations({ projectId: teamProject.id });
@ -764,18 +843,14 @@ describe('PATCH /projects/:projectId', () => {
const memberAgent = testServer.authAgentFor(testUser2);
const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({
name: teamProject.name,
relations: [
{ userId: testUser1.id, role: 'project:viewer' },
{ userId: testUser2.id, role: 'project:admin' },
{ userId: testUser3.id, role: 'project:admin' },
] as Array<{
userId: string;
role: ProjectRole;
}>,
});
expect(resp.status).toBe(200);
const resp1 = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser2.id}`)
.send({ role: 'project:admin' });
expect(resp1.status).toBe(204);
const resp2 = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser3.id}`)
.send({ role: 'project:admin' });
expect(resp2.status).toBe(204);
const tpRelations = await getProjectRelations({ projectId: teamProject.id });
expect(tpRelations.length).toBe(3);
@ -795,11 +870,8 @@ describe('PATCH /projects/:projectId', () => {
const memberAgent = testServer.authAgentFor(testUser1);
const resp = await memberAgent.patch(`/projects/${personalProject.id}`).send({
relations: [
{ userId: testUser1.id, role: PROJECT_OWNER_ROLE_SLUG },
{ userId: testUser2.id, role: 'project:admin' },
] as Array<{
const resp = await memberAgent.post(`/projects/${personalProject.id}/users`).send({
relations: [{ userId: testUser2.id, role: 'project:admin' }] as Array<{
userId: string;
role: ProjectRole;
}>,

View File

@ -675,6 +675,9 @@ describe('Projects in Public API', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
// Enable role licenses required for role change operations
testServer.license.enable('feat:projectRole:viewer');
testServer.license.enable('feat:projectRole:editor');
});
it('should reject with 400 if the role do not exist', async () => {

View File

@ -3111,10 +3111,11 @@
"projects.menu.personal": "Personal",
"projects.menu.addFirstProject": "Add project",
"projects.settings": "Project settings",
"projects.settings.info": "Project info",
"projects.settings.newProjectName": "My project",
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
"projects.settings.name": "Project icon and name",
"projects.settings.description": "Project description",
"projects.settings.name": "Icon and name",
"projects.settings.description": "Description",
"projects.settings.projectMembers": "Project members",
"projects.settings.message.unsavedChanges": "You have unsaved changes",
"projects.settings.danger.message": "When deleting a project, you can also choose to move all workflows and credentials to another project.",
@ -3155,6 +3156,7 @@
"projects.settings.memberRole.update.error.title": "An error occurred while updating member role",
"projects.settings.member.removed.title": "Member removed successfully",
"projects.settings.member.remove.error.title": "An error occurred while removing member",
"projects.settings.member.added.title": "Member added successfully",
"projects.sharing.noMatchingProjects": "There are no available projects",
"projects.sharing.noMatchingUsers": "No matching users or projects",
"projects.sharing.select.placeholder": "Select project or user",

View File

@ -2,6 +2,7 @@ import type { IRestApiContext } from '@n8n/rest-api-client';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types';
import type { CreateProjectDto, UpdateProjectDto } from '@n8n/api-types';
import type { AssignableProjectRole } from '@n8n/permissions';
export const getAllProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects');
@ -50,3 +51,28 @@ export const deleteProject = async (
export const getProjectsCount = async (context: IRestApiContext): Promise<ProjectsCount> => {
return await makeRestApiRequest(context, 'GET', '/projects/count');
};
export const addProjectMembers = async (
context: IRestApiContext,
projectId: string,
relations: Array<{ userId: string; role: AssignableProjectRole }>,
): Promise<void> => {
await makeRestApiRequest(context, 'POST', `/projects/${projectId}/users`, { relations });
};
export const updateProjectMemberRole = async (
context: IRestApiContext,
projectId: string,
userId: string,
role: AssignableProjectRole,
): Promise<void> => {
await makeRestApiRequest(context, 'PATCH', `/projects/${projectId}/users/${userId}`, { role });
};
export const deleteProjectMember = async (
context: IRestApiContext,
projectId: string,
userId: string,
): Promise<void> => {
await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/users/${userId}`);
};

View File

@ -381,7 +381,7 @@ const onSelect = (action: string) => {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-bottom: var(--spacing-m);
padding-bottom: var(--spacing-l);
min-height: var(--spacing-3xl);
}

View File

@ -15,6 +15,9 @@ vi.mock('vue-router', () => ({
vi.mock('@/api/projects.api', () => ({
updateProject: vi.fn(),
getProject: vi.fn(),
addProjectMembers: vi.fn(),
updateProjectMemberRole: vi.fn(),
deleteProjectMember: vi.fn(),
}));
// Typed mocked facade for the API module
@ -93,14 +96,71 @@ describe('useProjectsStore.updateProject (partial payloads)', () => {
expect(mockedProjectsApi.getProject).not.toHaveBeenCalled();
});
it('refetches project when relations are provided and does not touch name/icon/description', async () => {
it('addMember calls API and refreshes current project', async () => {
const store = makeStoreWithProject();
mockedProjectsApi.updateProject.mockResolvedValue(undefined);
mockedProjectsApi.addProjectMembers.mockResolvedValue(undefined);
const now = new Date().toISOString();
const serverProject: Project = {
id: 'p1',
name: 'SERVER',
description: 'SERVER',
name: 'A',
description: 'desc',
icon: { type: 'icon', value: 'layers' },
type: ProjectTypes.Team,
createdAt: now,
updatedAt: now,
relations: [
{ id: 'u1', email: 'x@y.z', firstName: 'X', lastName: 'Y', role: 'project:editor' },
{ id: 'u2', email: 'a@b.c', firstName: 'A', lastName: 'B', role: 'project:viewer' },
],
scopes: ['project:read' as Scope],
};
mockedProjectsApi.getProject.mockResolvedValue(serverProject);
await store.addMember('p1', { userId: 'u2', role: 'project:viewer' });
expect(mockedProjectsApi.addProjectMembers).toHaveBeenCalledWith(expect.anything(), 'p1', [
{ userId: 'u2', role: 'project:viewer' },
]);
expect(mockedProjectsApi.getProject).toHaveBeenCalledWith(expect.anything(), 'p1');
expect(store.currentProject?.relations.length).toBe(2);
});
it('updateMemberRole calls API and refreshes current project', async () => {
const store = makeStoreWithProject();
mockedProjectsApi.updateProjectMemberRole.mockResolvedValue(undefined);
const now = new Date().toISOString();
const serverProject: Project = {
id: 'p1',
name: 'A',
description: 'desc',
icon: { type: 'icon', value: 'layers' },
type: ProjectTypes.Team,
createdAt: now,
updatedAt: now,
relations: [
{ id: 'u1', email: 'x@y.z', firstName: 'X', lastName: 'Y', role: 'project:viewer' },
],
scopes: ['project:read' as Scope],
};
mockedProjectsApi.getProject.mockResolvedValue(serverProject);
await store.updateMemberRole('p1', 'u1', 'project:viewer');
expect(mockedProjectsApi.updateProjectMemberRole).toHaveBeenCalledWith(
expect.anything(),
'p1',
'u1',
'project:viewer',
);
expect(mockedProjectsApi.getProject).toHaveBeenCalledWith(expect.anything(), 'p1');
});
it('removeMember calls API and refreshes current project', async () => {
const store = makeStoreWithProject();
mockedProjectsApi.deleteProjectMember.mockResolvedValue(undefined);
const now = new Date().toISOString();
const serverProject: Project = {
id: 'p1',
name: 'A',
description: 'desc',
icon: { type: 'icon', value: 'layers' },
type: ProjectTypes.Team,
createdAt: now,
@ -110,18 +170,13 @@ describe('useProjectsStore.updateProject (partial payloads)', () => {
};
mockedProjectsApi.getProject.mockResolvedValue(serverProject);
await store.updateProject('p1', { relations: [{ userId: 'u2', role: 'project:viewer' }] });
// Ensure only relations were sent
expect(mockedProjectsApi.updateProject).toHaveBeenCalledWith(expect.anything(), 'p1', {
relations: [{ userId: 'u2', role: 'project:viewer' }],
});
// Refetch invoked
await store.removeMember('p1', 'u1');
expect(mockedProjectsApi.deleteProjectMember).toHaveBeenCalledWith(
expect.anything(),
'p1',
'u1',
);
expect(mockedProjectsApi.getProject).toHaveBeenCalledWith(expect.anything(), 'p1');
// Local name/description remain unchanged eagerly; currentProject then replaced by getProject
expect(store.myProjects[0].name).toBe('A');
expect(store.myProjects[0].description).toBe('desc');
expect(store.currentProject?.name).toBe('SERVER');
expect(store.currentProject?.description).toBe('SERVER');
expect(store.currentProject?.relations.length).toBe(0);
});
});

View File

@ -125,25 +125,48 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
};
const updateProject = async (id: Project['id'], projectData: UpdateProjectDto): Promise<void> => {
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
const { name, icon, description } = projectData;
const payload: UpdateProjectDto = {};
if (name !== undefined) payload.name = name;
if (icon !== undefined) payload.icon = icon;
if (description !== undefined) payload.description = description;
await projectsApi.updateProject(rootStore.restApiContext, id, payload);
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
const { name, icon, description, relations } = projectData;
const { name: nm, icon: ic, description: desc } = { name, icon, description };
if (projectIndex !== -1) {
if (typeof name !== 'undefined') myProjects.value[projectIndex].name = name;
if (typeof icon !== 'undefined') myProjects.value[projectIndex].icon = icon;
if (typeof description !== 'undefined')
myProjects.value[projectIndex].description = description;
if (nm !== undefined) myProjects.value[projectIndex].name = nm;
if (ic !== undefined) myProjects.value[projectIndex].icon = ic;
if (desc !== undefined) myProjects.value[projectIndex].description = desc;
}
if (currentProject.value) {
if (typeof name !== 'undefined') currentProject.value.name = name;
if (typeof icon !== 'undefined') currentProject.value.icon = icon;
if (typeof description !== 'undefined') currentProject.value.description = description;
}
if (relations) {
await getProject(id);
if (nm !== undefined) currentProject.value.name = nm;
if (ic !== undefined) currentProject.value.icon = ic;
if (desc !== undefined) currentProject.value.description = desc;
}
};
const addMember = async (
projectId: string,
{ userId, role }: { userId: string; role: string },
): Promise<void> => {
await projectsApi.addProjectMembers(rootStore.restApiContext, projectId, [{ userId, role }]);
await getProject(projectId);
};
const updateMemberRole = async (
projectId: string,
userId: string,
role: string,
): Promise<void> => {
await projectsApi.updateProjectMemberRole(rootStore.restApiContext, projectId, userId, role);
await getProject(projectId);
};
const removeMember = async (projectId: string, userId: string): Promise<void> => {
await projectsApi.deleteProjectMember(rootStore.restApiContext, projectId, userId);
await getProject(projectId);
};
const deleteProject = async (projectId: string, transferId?: string): Promise<void> => {
await projectsApi.deleteProject(rootStore.restApiContext, projectId, transferId);
await getProjectsCount();
@ -286,6 +309,9 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
getProject,
createProject,
updateProject,
addMember,
updateMemberRole,
removeMember,
deleteProject,
getProjectsCount,
setProjectNavActiveIdByWorkflowHomeProject,

View File

@ -312,17 +312,16 @@ describe('ProjectSettings', () => {
});
it('renders core form elements and initializes state', async () => {
const { getByTestId } = renderComponent();
const { getByTestId, queryByTestId } = renderComponent();
await nextTick();
expect(getByTestId('project-settings-container')).toBeInTheDocument();
const nameInput = getByTestId('project-settings-name-input');
const descriptionInput = getByTestId('project-settings-description-input');
const saveButton = getByTestId('project-settings-save-button');
const cancelButton = getByTestId('project-settings-cancel-button');
expect(nameInput).toBeInTheDocument();
expect(descriptionInput).toBeInTheDocument();
expect(saveButton).toBeDisabled();
expect(cancelButton).toBeDisabled();
// Save/Cancel are not rendered until form is dirty
expect(queryByTestId('project-settings-save-button')).toBeNull();
expect(queryByTestId('project-settings-cancel-button')).toBeNull();
const actualName = getInput(nameInput);
const actualDesc = getTextarea(descriptionInput);
expect(actualName.value).toBe('Test Project');
@ -332,18 +331,19 @@ describe('ProjectSettings', () => {
describe('Form interactions', () => {
it('marks dirty, cancels reset, and saves via Enter and button', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
const { getByTestId, queryByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const saveButton = getByTestId('project-settings-save-button');
const cancelButton = getByTestId('project-settings-cancel-button');
const actualInput = getInput(nameInput);
// Dirty then cancel
await userEvent.type(actualInput, ' Extra');
expect(cancelButton).toBeEnabled();
await userEvent.click(cancelButton);
const cancelBtn1 = getByTestId('project-settings-cancel-button');
expect(cancelBtn1).toBeEnabled();
await userEvent.click(cancelBtn1);
expect(actualInput.value).toBe('Test Project');
expect(cancelButton).toBeDisabled();
// Buttons disappear when clean
expect(queryByTestId('project-settings-cancel-button')).toBeNull();
expect(queryByTestId('project-settings-save-button')).toBeNull();
// Save via Enter
await userEvent.type(actualInput, ' - Updated');
@ -361,8 +361,9 @@ describe('ProjectSettings', () => {
// Save via button
await userEvent.type(actualInput, ' Again');
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
const saveBtn = getByTestId('project-settings-save-button');
expect(saveBtn).toBeEnabled();
await userEvent.click(saveBtn);
await nextTick();
expect(updateSpy).toHaveBeenCalledTimes(2);
expect(mockShowMessage).toHaveBeenCalledTimes(2);
@ -375,7 +376,6 @@ describe('ProjectSettings', () => {
projectsStore.updateProject.mockRejectedValue(error);
const { getByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const saveButton = getByTestId('project-settings-save-button');
const actualInput = getInput(nameInput);
await userEvent.type(actualInput, ' Updated');
@ -387,8 +387,9 @@ describe('ProjectSettings', () => {
projectsStore.updateProject.mockClear();
await userEvent.clear(actualInput);
expect(saveButton).toBeDisabled();
await userEvent.click(saveButton);
const saveButton2 = getByTestId('project-settings-save-button');
expect(saveButton2).toBeDisabled();
await userEvent.click(saveButton2);
expect(projectsStore.updateProject).not.toHaveBeenCalled();
});
});
@ -396,28 +397,28 @@ describe('ProjectSettings', () => {
describe('Save state and validation', () => {
it('maintains state after save and validation toggles', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
const { getByTestId, queryByTestId } = renderComponent();
const nameInput = getByTestId('project-settings-name-input');
const cancelButton = getByTestId('project-settings-cancel-button');
const saveButton = getByTestId('project-settings-save-button');
const actualInput = getInput(nameInput);
await userEvent.clear(actualInput);
const saveButton = getByTestId('project-settings-save-button');
expect(saveButton).toBeDisabled();
await userEvent.type(actualInput, 'Valid Project Name');
expect(saveButton).toBeEnabled();
expect(cancelButton).toBeEnabled();
expect(getByTestId('project-settings-cancel-button')).toBeEnabled();
await userEvent.click(saveButton);
await nextTick();
expect(updateSpy).toHaveBeenCalled();
expect(cancelButton).toBeDisabled();
expect(saveButton).toBeDisabled();
// Buttons removed when clean again
expect(queryByTestId('project-settings-cancel-button')).toBeNull();
expect(queryByTestId('project-settings-save-button')).toBeNull();
});
});
describe('Members table and role updates', () => {
it('adds a member and saves with telemetry', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
it('adds a member immediately and emits telemetry', async () => {
const addSpy = vi.spyOn(projectsStore, 'addMember').mockResolvedValue(undefined);
const { getByTestId } = renderComponent();
await nextTick();
@ -428,14 +429,8 @@ describe('ProjectSettings', () => {
emitters.n8nUserSelect.emit('update:model-value', '2');
await nextTick();
expect(getByTestId('members-count').textContent).toBe('2');
// Ensure form valid + dirty; name keystroke triggers validate and enables save
const nameInput = getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' ');
await userEvent.click(getByTestId('project-settings-save-button'));
await nextTick();
expect(updateSpy).toHaveBeenCalled();
expect(addSpy).toHaveBeenCalledWith('123', expect.objectContaining({ userId: '2' }));
expect(mockShowMessage).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User added member to project',
expect.objectContaining({ project_id: '123', target_user_id: '2' }),
@ -460,20 +455,13 @@ describe('ProjectSettings', () => {
});
it('inline role change saves immediately with telemetry', async () => {
projectsStore.updateProject.mockResolvedValue(undefined);
const roleSpy = vi.spyOn(projectsStore, 'updateMemberRole').mockResolvedValue(undefined);
renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:editor' });
await nextTick();
expect(projectsStore.updateProject).toHaveBeenCalledWith(
'123',
expect.objectContaining({
relations: expect.arrayContaining([
expect.objectContaining({ userId: '1', role: 'project:editor' }),
]),
}),
);
expect(roleSpy).toHaveBeenCalledWith('123', '1', 'project:editor');
expect(mockShowMessage).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User changed member role on project',
@ -483,26 +471,28 @@ describe('ProjectSettings', () => {
it('rolls back role on save error', async () => {
// First, inline update fails and rolls back
projectsStore.updateProject.mockRejectedValueOnce(new Error('fail'));
const utils = renderComponent();
vi.spyOn(projectsStore, 'updateMemberRole').mockRejectedValueOnce(new Error('fail'));
const wrapper = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', { userId: '1', role: 'project:viewer' });
await nextTick();
expect(mockShowError).toHaveBeenCalled();
// Next form save should contain original role (admin) due to rollback
const nameInput = utils.getByTestId('project-settings-name-input');
// Next form save persists only name/description and succeeds
const nameInput = wrapper.getByTestId('project-settings-name-input');
await userEvent.type(getInput(nameInput), ' touch');
await userEvent.click(utils.getByTestId('project-settings-save-button'));
await userEvent.click(wrapper.getByTestId('project-settings-save-button'));
await nextTick();
const lastCall = projectsStore.updateProject.mock.calls.pop();
expect(lastCall?.[1].relations).toEqual(
expect.arrayContaining([expect.objectContaining({ userId: '1', role: 'project:admin' })]),
const payload = lastCall?.[1];
expect(payload).toEqual(
expect.objectContaining({ name: expect.any(String), description: expect.any(String) }),
);
expect(payload).not.toHaveProperty('relations');
});
it('removes member immediately and shows success toast with telemetry', async () => {
const updateSpy = vi.spyOn(projectsStore, 'updateProject').mockResolvedValue(undefined);
const removeSpy = vi.spyOn(projectsStore, 'removeMember').mockResolvedValue(undefined);
const { getByTestId, queryByTestId } = renderComponent();
await nextTick();
expect(getByTestId('members-count').textContent).toBe('1');
@ -513,44 +503,45 @@ describe('ProjectSettings', () => {
// Members list container should now be hidden
expect(queryByTestId('members-count')).toBeNull();
expect(updateSpy).toHaveBeenCalled();
const payload = updateSpy.mock.calls[0][1];
expect(payload.relations).toEqual([]);
expect(removeSpy).toHaveBeenCalledWith('123', '1');
expect(mockShowMessage).toHaveBeenCalled();
expect(mockTrack).toHaveBeenCalledWith(
'User removed member from project',
expect.objectContaining({ project_id: '123', target_user_id: '1' }),
);
// Save should not re-add removed user
await userEvent.click(getByTestId('project-settings-save-button'));
await nextTick();
expect(queryByTestId('members-count')).toBeNull();
// Save button is not shown (no form edits)
expect(queryByTestId('project-settings-save-button')).toBeNull();
});
it('prevents saving when invalid role selected', async () => {
// Set invalid role and try to save
const utils = renderComponent();
it('saves only name and description with Save button', async () => {
const wrapper = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:role', {
userId: '1',
role: 'project:personalOwner',
});
// Edit name and description
const nameInput = wrapper.getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' New');
const descInput = wrapper.getByTestId('project-settings-description-input');
await userEvent.type(descInput.querySelector('textarea')!, 'New description');
// Save
await userEvent.click(wrapper.getByTestId('project-settings-save-button'));
await nextTick();
// Clear prior success toast from inline update (if any)
mockShowMessage.mockClear();
// Mark form dirty so save is enabled
const nameInput = utils.getByTestId('project-settings-name-input');
await userEvent.type(nameInput.querySelector('input')!, ' ');
await userEvent.click(utils.getByTestId('project-settings-save-button'));
await nextTick();
expect(mockShowError).toHaveBeenCalled();
// Should not show success on invalid role
expect(mockShowMessage).not.toHaveBeenCalled();
expect(projectsStore.updateProject).toHaveBeenCalled();
const lastCall = projectsStore.updateProject.mock.calls.pop();
const [id, payload] = lastCall as unknown as [string, Record<string, unknown>];
expect(id).toBe('123');
expect(payload).toEqual(
expect.objectContaining({ name: expect.any(String), description: expect.any(String) }),
);
expect(payload).not.toHaveProperty('relations');
expect(payload).not.toHaveProperty('icon');
expect(mockShowMessage).toHaveBeenCalled();
});
it('resets pagination to first page on search', async () => {
const utils = renderComponent();
const wrapper = renderComponent();
await nextTick();
emitters.projectMembersTable.emit('update:options', {
page: 2,
@ -558,14 +549,14 @@ describe('ProjectSettings', () => {
sortBy: [],
});
await nextTick();
const searchContainer = utils.getByTestId('project-members-search');
const searchContainer = wrapper.getByTestId('project-members-search');
const searchInput = searchContainer.querySelector('input')!;
await userEvent.type(searchInput, 'john');
await new Promise((r) => setTimeout(r, 350));
// unmount first to avoid duplicate elements
utils.unmount();
const utils2 = renderComponent();
expect(utils2.getByTestId('members-page').textContent).toBe('0');
wrapper.unmount();
const wrapper2 = renderComponent();
expect(wrapper2.getByTestId('members-page').textContent).toBe('0');
});
});

View File

@ -6,7 +6,6 @@ import { deepCopy } from 'n8n-workflow';
import { N8nFormInput, N8nInput } from '@n8n/design-system';
import { useDebounceFn } from '@vueuse/core';
import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { useI18n } from '@n8n/i18n';
import { type ResourceCounts, useProjectsStore } from '@/stores/projects.store';
import type { Project, ProjectRelation } from '@/types/projects.types';
@ -44,6 +43,10 @@ const router = useRouter();
const telemetry = useTelemetry();
const documentTitle = useDocumentTitle();
const showSaveError = (error: Error) => {
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
};
const dialogVisible = ref(false);
const upgradeDialogVisible = ref(false);
@ -86,10 +89,8 @@ const membersTableState = ref<TableOptions>({
});
const usersList = computed(() =>
usersStore.allUsers.filter((user: IUser) => {
const isAlreadySharedWithUser = (formData.value.relations || []).find(
(r: ProjectRelation) => r.id === user.id,
);
usersStore.allUsers.filter((user) => {
const isAlreadySharedWithUser = (formData.value.relations || []).find((r) => r.id === user.id);
return !isAlreadySharedWithUser;
}),
@ -117,19 +118,36 @@ const projectMembersActions = computed<Array<UserAction<ProjectMemberData>>>(()
},
]);
const onAddMember = (userId: string) => {
isDirty.value = true;
const onAddMember = async (userId: string) => {
if (!projectsStore.currentProject) return;
const user = usersStore.usersById[userId];
if (!user) return;
const { id, firstName, lastName, email } = user;
const relation = { id, firstName, lastName, email } as ProjectRelation;
const role = firstLicensedRole.value;
if (!role) return;
if (firstLicensedRole.value) {
relation.role = firstLicensedRole.value;
// Optimistically update UI
if (!formData.value.relations.find((r) => r.id === userId)) {
formData.value.relations.push({ id: userId, role });
}
formData.value.relations.push(relation);
try {
suppressNextSync.value = true;
await projectsStore.addMember(projectsStore.currentProject.id, { userId, role });
toast.showMessage({
type: 'success',
title: i18n.baseText('projects.settings.member.added.title'),
});
telemetry.track('User added member to project', {
project_id: projectsStore.currentProject.id,
target_user_id: userId,
role,
});
} catch (error) {
// Rollback optimistic change
formData.value.relations = formData.value.relations.filter((r) => r.id !== userId);
showSaveError(error);
}
};
const onUpdateMemberRole = async ({ userId, role }: { userId: string; role: ProjectRole }) => {
@ -149,13 +167,8 @@ const onUpdateMemberRole = async ({ userId, role }: { userId: string; role: Proj
formData.value.relations[memberIndex].role = role;
try {
await projectsStore.updateProject(projectsStore.currentProject.id, {
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
})),
});
suppressNextSync.value = true;
await projectsStore.updateMemberRole(projectsStore.currentProject.id, userId, role);
toast.showMessage({
type: 'success',
title: i18n.baseText('projects.settings.memberRole.updated.title'),
@ -193,11 +206,7 @@ async function onRemoveMember(userId: string) {
try {
// Prevent next sync from wiping unsaved edits
suppressNextSync.value = true;
await projectsStore.updateProject(current.id, {
relations: current.relations
.filter((r) => r.id !== userId)
.map((r) => ({ userId: r.id, role: r.role })),
});
await projectsStore.removeMember(current.id, userId);
toast.showMessage({
type: 'success',
title: i18n.baseText('projects.settings.member.removed.title'),
@ -208,7 +217,7 @@ async function onRemoveMember(userId: string) {
});
} catch (error) {
formData.value.relations.splice(idx, 0, removed);
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
showSaveError(error);
}
}
@ -223,12 +232,16 @@ const onMembersListAction = async ({ action, userId }: { action: string; userId:
}
};
const onCancel = () => {
const resetFormData = () => {
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
formData.value.name = projectsStore.currentProject?.name ?? '';
formData.value.description = projectsStore.currentProject?.description ?? '';
};
const onCancel = () => {
resetFormData();
isDirty.value = false;
};
@ -248,14 +261,14 @@ const makeFormDataDiff = (): FormDataDiff => {
if (formData.value.relations.length !== projectsStore.currentProject.relations.length) {
diff.memberAdded = formData.value.relations.filter(
(r: ProjectRelation) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
(r) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
);
diff.memberRemoved = projectsStore.currentProject.relations.filter(
(cr: ProjectRelation) => !formData.value.relations.find((r) => r.id === cr.id),
(cr) => !formData.value.relations.find((r) => r.id === cr.id),
);
}
diff.role = formData.value.relations.filter((r: ProjectRelation) => {
diff.role = formData.value.relations.filter((r) => {
const currentRelation = projectsStore.currentProject?.relations.find((cr) => cr.id === r.id);
return currentRelation?.role !== r.role && !diff.memberAdded?.find((ar) => ar.id === r.id);
});
@ -264,41 +277,34 @@ const makeFormDataDiff = (): FormDataDiff => {
};
const sendTelemetry = (diff: FormDataDiff) => {
const projectId = projectsStore.currentProject?.id;
if (diff.name) {
telemetry.track('User changed project name', {
project_id: projectsStore.currentProject?.id,
name: diff.name,
});
telemetry.track('User changed project name', { project_id: projectId, name: diff.name });
}
if (diff.memberAdded) {
diff.memberAdded.forEach((r) => {
telemetry.track('User added member to project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
diff.memberAdded?.forEach((r) => {
telemetry.track('User added member to project', {
project_id: projectId,
target_user_id: r.id,
role: r.role,
});
}
});
if (diff.memberRemoved) {
diff.memberRemoved.forEach((r) => {
telemetry.track('User removed member from project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
});
diff.memberRemoved?.forEach((r) => {
telemetry.track('User removed member from project', {
project_id: projectId,
target_user_id: r.id,
});
}
});
if (diff.role) {
diff.role.forEach((r) => {
telemetry.track('User changed member role on project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
diff.role?.forEach((r) => {
telemetry.track('User changed member role on project', {
project_id: projectId,
target_user_id: r.id,
role: r.role,
});
}
});
};
const updateProject = async () => {
@ -306,22 +312,13 @@ const updateProject = async () => {
return;
}
try {
if (formData.value.relations.some((r) => r.role === 'project:personalOwner')) {
throw new Error('Invalid role selected for this project.');
}
await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name ?? '',
icon: projectIcon.value,
description: formData.value.description ?? '',
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
})),
});
isDirty.value = false;
} catch (error) {
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
showSaveError(error);
throw error;
}
};
@ -383,11 +380,18 @@ const selectProjectNameIfMatchesDefault = () => {
};
const onIconUpdated = async () => {
await updateProject();
toast.showMessage({
title: i18n.baseText('projects.settings.icon.update.successful.title'),
type: 'success',
});
if (!projectsStore.currentProject) return;
try {
await projectsStore.updateProject(projectsStore.currentProject.id, {
icon: projectIcon.value,
});
toast.showMessage({
title: i18n.baseText('projects.settings.icon.update.successful.title'),
type: 'success',
});
} catch (error) {
showSaveError(error);
}
};
// Skip one sync after targeted updates (e.g. removal) to preserve unsaved edits
@ -398,11 +402,7 @@ watch(
suppressNextSync.value = false;
return;
}
formData.value.name = projectsStore.currentProject?.name ?? '';
formData.value.description = projectsStore.currentProject?.description ?? '';
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
resetFormData();
await nextTick();
selectProjectNameIfMatchesDefault();
if (projectsStore.currentProject?.icon && isIconOrEmoji(projectsStore.currentProject.icon)) {
@ -415,24 +415,17 @@ watch(
// Add users property to the relation objects,
// So that the table has access to the full user data
const relationUsers = computed(() =>
formData.value.relations.map((relation: ProjectRelation) => {
formData.value.relations.map((relation) => {
const user = usersStore.usersById[relation.id];
// Ensure type safety for UI display while preserving original role in formData
const safeRole: ProjectRole = isProjectRole(relation.role) ? relation.role : 'project:viewer';
const safeRole = isProjectRole(relation.role) ? relation.role : 'project:viewer';
if (!user) {
return {
...relation,
role: safeRole,
firstName: null,
lastName: null,
email: null,
};
}
return {
...user,
...relation,
role: safeRole,
firstName: user?.firstName ?? null,
lastName: user?.lastName ?? null,
email: user?.email ?? null,
};
}),
);
@ -443,19 +436,16 @@ const membersTableData = computed(() => ({
}));
const filteredMembersData = computed(() => {
if (!search.value.trim()) {
return membersTableData.value;
}
if (!search.value.trim()) return membersTableData.value;
const searchTerm = search.value.toLowerCase();
const filtered = relationUsers.value.filter((member) => {
const fullName = `${member.firstName || ''} ${member.lastName || ''}`.toLowerCase();
const email = (member.email || '').toLowerCase();
const fullName = `${member.firstName ?? ''} ${member.lastName ?? ''}`.toLowerCase();
const email = (member.email ?? '').toLowerCase();
return fullName.includes(searchTerm) || email.includes(searchTerm);
});
return {
items: filtered,
count: filtered.length,
};
return { items: filtered, count: filtered.length };
});
const debouncedSearch = useDebounceFn(() => {
@ -485,11 +475,14 @@ onMounted(() => {
<div :class="$style.projectSettings" data-test-id="project-settings-container">
<div :class="$style.header">
<ProjectHeader />
<N8nText tag="h1" size="xlarge" class="pt-xs pb-m">
{{ i18n.baseText('projects.settings.info') }}
</N8nText>
</div>
<form @submit.prevent="onSubmit">
<fieldset>
<label for="projectName">{{ i18n.baseText('projects.settings.name') }}</label>
<div :class="$style['project-name']">
<div :class="$style.projectName">
<N8nIconPicker
v-model="projectIcon"
:button-tooltip="i18n.baseText('projects.settings.iconPicker.button.tooltip')"
@ -504,7 +497,7 @@ onMounted(() => {
name="name"
required
data-test-id="project-settings-name-input"
:class="$style['project-name-input']"
:class="$style.projectNameInput"
@enter="onSubmit"
@input="onTextInput"
@validate="isValid = $event"
@ -522,15 +515,42 @@ onMounted(() => {
:maxlength="512"
:autosize="true"
data-test-id="project-settings-description-input"
:class="$style.projectDescriptionInput"
@enter="onSubmit"
@input="onTextInput"
@validate="isValid = $event"
/>
</fieldset>
<fieldset v-if="isDirty" :class="$style.buttons">
<div>
<small class="mr-2xs">{{
i18n.baseText('projects.settings.message.unsavedChanges')
}}</small>
<N8nButton
type="secondary"
native-type="button"
class="mr-2xs"
data-test-id="project-settings-cancel-button"
@click.stop.prevent="onCancel"
>{{ i18n.baseText('projects.settings.button.cancel') }}</N8nButton
>
</div>
<N8nButton
:disabled="!isValid"
type="primary"
data-test-id="project-settings-save-button"
>{{ i18n.baseText('projects.settings.button.save') }}</N8nButton
>
</fieldset>
<fieldset>
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
<h3>
<label for="projectMembers">{{
i18n.baseText('projects.settings.projectMembers')
}}</label>
</h3>
<N8nUserSelect
id="projectMembers"
:class="$style.userSelect"
class="mb-s"
size="large"
:users="usersList"
@ -569,30 +589,7 @@ onMounted(() => {
/>
</div>
</fieldset>
<fieldset :class="$style.buttons">
<div>
<small v-if="isDirty" class="mr-2xs">{{
i18n.baseText('projects.settings.message.unsavedChanges')
}}</small>
<N8nButton
:disabled="!isDirty"
type="secondary"
native-type="button"
class="mr-2xs"
data-test-id="project-settings-cancel-button"
@click.stop.prevent="onCancel"
>{{ i18n.baseText('projects.settings.button.cancel') }}</N8nButton
>
</div>
<N8nButton
:disabled="!isDirty || !isValid"
type="primary"
data-test-id="project-settings-save-button"
>{{ i18n.baseText('projects.settings.button.save') }}</N8nButton
>
</fieldset>
<fieldset>
<hr class="mb-2xl" />
<h3 class="mb-xs">{{ i18n.baseText('projects.settings.danger.title') }}</h3>
<small>{{ i18n.baseText('projects.settings.danger.message') }}</small>
<br />
@ -623,6 +620,8 @@ onMounted(() => {
<style lang="scss" module>
.projectSettings {
--project-field-width: 560px;
display: grid;
width: 100%;
justify-items: center;
@ -634,12 +633,18 @@ onMounted(() => {
padding: 0 var(--spacing-2xl);
fieldset {
padding-bottom: var(--spacing-2xl);
padding-bottom: var(--spacing-xl);
h3 {
label {
font-size: var(--font-size-l);
}
}
label {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-xl);
font-size: var(--font-size-s);
}
}
}
@ -657,7 +662,7 @@ onMounted(() => {
.buttons {
display: flex;
justify-content: flex-end;
justify-content: flex-start;
align-items: center;
}
@ -666,16 +671,28 @@ onMounted(() => {
}
.search {
max-width: 300px;
max-width: var(--project-field-width);
margin-bottom: var(--spacing-s);
}
.project-name {
.projectName {
display: flex;
gap: var(--spacing-2xs);
max-width: var(--project-field-width);
.project-name-input {
.projectNameInput {
flex: 1;
}
}
.projectDescriptionInput,
.userSelect {
max-width: var(--project-field-width);
width: 100%;
}
/* Ensure textarea uses regular UI font, not monospace */
.projectDescriptionInput :global(textarea) {
font-family: var(--font-family);
}
</style>

View File

@ -123,9 +123,11 @@ test.describe('Projects', () => {
// Initially should have only the owner (current user)
await n8n.projectSettings.expectTableHasMemberCount(1);
// Verify project settings action buttons are present
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeVisible();
// Verify save/cancel buttons are not visible initially (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
// Delete button should always be visible
await expect(n8n.page.getByTestId('project-settings-delete-button')).toBeVisible();
});
@ -254,9 +256,9 @@ test.describe('Projects', () => {
await n8n.page.goto(`/projects/${projectId}/settings`);
await expect(n8n.projectSettings.getTitle()).toHaveText('Unsaved Changes Test');
// Initially, save and cancel buttons should be disabled (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeDisabled();
// Initially, save and cancel buttons should not be visible (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
// Make a change to the project name
await n8n.projectSettings.fillProjectName('Modified Name');
@ -271,9 +273,9 @@ test.describe('Projects', () => {
// Cancel changes
await n8n.projectSettings.clickCancelButton();
// Buttons should be disabled again
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeDisabled();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeDisabled();
// Buttons should not be visible again (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
});
test('should display delete project section with warning @auth:owner', async ({ n8n }) => {