From c93e4380bcf5df194c8e368e764feb32cace1efa Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 27 Feb 2026 11:33:41 +0100 Subject: [PATCH] feat: Add assignments tab for custom roles with project visibility (#25992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Declan Carroll Co-authored-by: Milorad FIlipović Co-authored-by: Ricardo Espinoza Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Charlie Kolb Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Ali Elkhateeb Co-authored-by: Matsu Co-authored-by: Guillaume Jacquart Co-authored-by: yehorkardash Co-authored-by: Rob Hough Co-authored-by: Svetoslav Dekov Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com> Co-authored-by: Alex Grozav Co-authored-by: Dawid Myslak Co-authored-by: Stephen Wright Co-authored-by: Andreas Fitzek Co-authored-by: Albert Alises Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com> Co-authored-by: Danny Martini Co-authored-by: Daria Co-authored-by: peteawood Co-authored-by: Roman Davydchuk Co-authored-by: Jaakko Husso Co-authored-by: Elias Meire Co-authored-by: Cursor Co-authored-by: Eugene Co-authored-by: Benjamin Schroth Co-authored-by: Sandra Zollner Co-authored-by: Irénée Co-authored-by: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Co-authored-by: oleg Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Co-authored-by: Nikhil Kuriakose Co-authored-by: Romeo Balta <7095569+romeobalta@users.noreply.github.com> Co-authored-by: Tuukka Kantola Co-authored-by: Kaito Horiuchi Co-authored-by: Michael Drury Co-authored-by: Claude Opus 4.6 --- packages/@n8n/api-types/src/dto/index.ts | 10 + .../roles/role-assignments-response.dto.ts | 25 ++ .../role-project-members-response.dto.ts | 19 + .../db/src/repositories/role.repository.ts | 97 ++++- packages/@n8n/permissions/src/schemas.ee.ts | 1 + .../cli/src/controllers/role.controller.ts | 33 +- .../role.service.assignments.test.ts | 249 +++++++++++++ packages/cli/src/services/role.service.ts | 53 ++- .../controllers/role.controller-db.test.ts | 70 +++- .../frontend/@n8n/i18n/src/locales/en.json | 17 +- .../@n8n/rest-api-client/src/api/roles.ts | 26 +- .../editor-ui/src/app/stores/roles.store.ts | 20 +- .../project-roles/ProjectRoleView.test.ts | 1 - .../project-roles/ProjectRoleView.vue | 255 +++++++++----- .../project-roles/ProjectRolesView.test.ts | 2 +- .../project-roles/ProjectRolesView.vue | 80 ++++- .../project-roles/RoleAssignmentsTab.test.ts | 333 ++++++++++++++++++ .../project-roles/RoleAssignmentsTab.vue | 185 ++++++++++ .../project-roles/RoleProjectMembersModal.vue | 143 ++++++++ 19 files changed, 1497 insertions(+), 122 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/roles/role-assignments-response.dto.ts create mode 100644 packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts create mode 100644 packages/cli/src/services/__tests__/role.service.assignments.test.ts create mode 100644 packages/frontend/editor-ui/src/features/project-roles/RoleAssignmentsTab.test.ts create mode 100644 packages/frontend/editor-ui/src/features/project-roles/RoleAssignmentsTab.vue create mode 100644 packages/frontend/editor-ui/src/features/project-roles/RoleProjectMembersModal.vue diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index c408a39bb4c..16f32f38896 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -115,6 +115,16 @@ export { UpdateRoleDto } from './roles/update-role.dto'; export { CreateRoleDto } from './roles/create-role.dto'; export { RoleListQueryDto } from './roles/role-list-query.dto'; export { RoleGetQueryDto } from './roles/role-get-query.dto'; +export { + RoleAssignmentsResponseDto, + type RoleProjectAssignment, + type RoleAssignmentsResponse, +} from './roles/role-assignments-response.dto'; +export { + RoleProjectMembersResponseDto, + type RoleProjectMember, + type RoleProjectMembersResponse, +} from './roles/role-project-members-response.dto'; export { OidcConfigDto } from './oidc/config.dto'; diff --git a/packages/@n8n/api-types/src/dto/roles/role-assignments-response.dto.ts b/packages/@n8n/api-types/src/dto/roles/role-assignments-response.dto.ts new file mode 100644 index 00000000000..5f2f12e1b96 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/roles/role-assignments-response.dto.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +const roleProjectAssignmentSchema = z.object({ + projectId: z.string(), + projectName: z.string(), + projectIcon: z + .object({ + type: z.string(), + value: z.string(), + }) + .nullable(), + memberCount: z.number(), + lastAssigned: z.string().nullable(), +}); + +export type RoleProjectAssignment = z.infer; + +export class RoleAssignmentsResponseDto extends Z.class({ + projects: z.array(roleProjectAssignmentSchema), + totalProjects: z.number(), +}) {} + +export type RoleAssignmentsResponse = InstanceType; diff --git a/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts b/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts new file mode 100644 index 00000000000..d3ca99e4734 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +const roleProjectMemberSchema = z.object({ + userId: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + email: z.string(), + role: z.string(), +}); + +export type RoleProjectMember = z.infer; + +export class RoleProjectMembersResponseDto extends Z.class({ + members: z.array(roleProjectMemberSchema), +}) {} + +export type RoleProjectMembersResponse = InstanceType; diff --git a/packages/@n8n/db/src/repositories/role.repository.ts b/packages/@n8n/db/src/repositories/role.repository.ts index 84a44040f69..70b164edcac 100644 --- a/packages/@n8n/db/src/repositories/role.repository.ts +++ b/packages/@n8n/db/src/repositories/role.repository.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { DataSource, EntityManager, In, Repository } from '@n8n/typeorm'; import { UserError } from 'n8n-workflow'; -import { ProjectRelation, Role, User } from '../entities'; +import { Project, ProjectRelation, Role, User } from '../entities'; @Service() export class RoleRepository extends Repository { @@ -60,6 +60,101 @@ export class RoleRepository extends Repository { ); } + async findAllProjectCounts(): Promise> { + const results = await this.manager + .createQueryBuilder(ProjectRelation, 'pr') + .select('pr.role', 'roleSlug') + .addSelect('COUNT(DISTINCT pr.projectId)', 'count') + .groupBy('pr.role') + .getRawMany<{ roleSlug: string; count: string }>(); + + return results.reduce( + (acc, { roleSlug, count }) => { + acc[roleSlug] = parseInt(count, 10); + return acc; + }, + {} as Record, + ); + } + + async findProjectAssignments(roleSlug: string): Promise< + Array<{ + projectId: string; + projectName: string; + projectIcon: { type: string; value: string } | null; + memberCount: number; + lastAssigned: string | null; + }> + > { + // First get member counts per project for this role + const counts = await this.manager + .createQueryBuilder(ProjectRelation, 'pr') + .select('pr.projectId', 'projectId') + .addSelect('COUNT(pr.userId)', 'memberCount') + .addSelect('MAX(pr.createdAt)', 'lastAssigned') + .where('pr.role = :roleSlug', { roleSlug }) + .groupBy('pr.projectId') + .getRawMany<{ + projectId: string; + memberCount: string; + lastAssigned: string | Date | null; + }>(); + + if (counts.length === 0) return []; + + // Then fetch project details separately to avoid JSON GROUP BY issues + const projectIds = counts.map((c) => c.projectId); + const projects = await this.manager.getRepository(Project).findBy({ id: In(projectIds) }); + + const projectMap = new Map(projects.map((p) => [p.id, p])); + + return counts + .map((c) => { + const project = projectMap.get(c.projectId); + if (!project) return null; + return { + projectId: project.id, + projectName: project.name, + projectIcon: project.icon, + memberCount: parseInt(c.memberCount, 10), + lastAssigned: + c.lastAssigned instanceof Date + ? c.lastAssigned.toISOString() + : (c.lastAssigned ?? null), + }; + }) + .filter((r) => r !== null); + } + + async findAllProjectMembers( + projectId: string, + roleSlug?: string, + ): Promise< + Array<{ + userId: string; + firstName: string | null; + lastName: string | null; + email: string; + role: string; + }> + > { + const qb = this.manager + .createQueryBuilder(ProjectRelation, 'pr') + .innerJoin(User, 'user', 'user.id = pr.userId') + .select('user.id', 'userId') + .addSelect('user.firstName', 'firstName') + .addSelect('user.lastName', 'lastName') + .addSelect('user.email', 'email') + .addSelect('pr.role', 'role') + .where('pr.projectId = :projectId', { projectId }); + + if (roleSlug) { + qb.andWhere('pr.role = :roleSlug', { roleSlug }); + } + + return await qb.getRawMany(); + } + async findBySlug(slug: string) { return await this.findOne({ where: { slug }, diff --git a/packages/@n8n/permissions/src/schemas.ee.ts b/packages/@n8n/permissions/src/schemas.ee.ts index 99107387d27..93643806be7 100644 --- a/packages/@n8n/permissions/src/schemas.ee.ts +++ b/packages/@n8n/permissions/src/schemas.ee.ts @@ -74,6 +74,7 @@ export const roleSchema = z.object({ createdAt: z.date().optional(), updatedAt: z.date().optional(), usedByUsers: z.number().optional(), + usedByProjects: z.number().optional(), }); export type Role = z.infer; diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index 39af0a4a9cc..2fa50c109b9 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -1,4 +1,12 @@ -import { CreateRoleDto, RoleGetQueryDto, RoleListQueryDto, UpdateRoleDto } from '@n8n/api-types'; +import { + CreateRoleDto, + RoleAssignmentsResponseDto, + RoleGetQueryDto, + RoleListQueryDto, + RoleProjectMembersResponseDto, + UpdateRoleDto, +} from '@n8n/api-types'; +import type { RoleAssignmentsResponse, RoleProjectMembersResponse } from '@n8n/api-types'; import { LICENSE_FEATURES } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { @@ -36,6 +44,29 @@ export class RoleController { }; } + @Get('/:slug/assignments/:projectId/members') + @GlobalScope('role:manage') + async getRoleProjectMembers( + _req: AuthenticatedRequest, + _res: Response, + @Param('slug') slug: string, + @Param('projectId') projectId: string, + ): Promise { + const result = await this.roleService.getRoleProjectMembers(slug, projectId); + return RoleProjectMembersResponseDto.parse(result); + } + + @Get('/:slug/assignments') + @GlobalScope('role:manage') + async getRoleAssignments( + _req: AuthenticatedRequest, + _res: Response, + @Param('slug') slug: string, + ): Promise { + const result = await this.roleService.getRoleAssignments(slug); + return RoleAssignmentsResponseDto.parse(result); + } + @Get('/:slug') async getRoleBySlug( _req: AuthenticatedRequest, diff --git a/packages/cli/src/services/__tests__/role.service.assignments.test.ts b/packages/cli/src/services/__tests__/role.service.assignments.test.ts new file mode 100644 index 00000000000..57446179f44 --- /dev/null +++ b/packages/cli/src/services/__tests__/role.service.assignments.test.ts @@ -0,0 +1,249 @@ +import type { LicenseState } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; +import { RoleRepository, ScopeRepository } from '@n8n/db'; +import { mock } from 'jest-mock-extended'; + +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { RoleCacheService } from '@/services/role-cache.service'; +import { RoleService } from '@/services/role.service'; +import { Logger } from '@n8n/backend-common'; + +describe('RoleService.getRoleAssignments and getRoleProjectMembers', () => { + const licenseState = mock(); + const roleRepository = mockInstance(RoleRepository); + const scopeRepository = mockInstance(ScopeRepository); + const roleCacheService = mockInstance(RoleCacheService); + const logger = mockInstance(Logger); + + const roleService = new RoleService( + licenseState, + roleRepository, + scopeRepository, + roleCacheService, + logger, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getRoleAssignments', () => { + it('should return project assignments for a valid role', async () => { + const mockRole = { + slug: 'project:editor', + displayName: 'Editor', + description: null, + systemRole: true, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + const mockProjects = [ + { + projectId: 'project-1', + projectName: 'My Project', + projectIcon: { type: 'emoji', value: '🚀' }, + memberCount: 3, + lastAssigned: '2026-01-15T10:00:00.000Z', + }, + { + projectId: 'project-2', + projectName: 'Another Project', + projectIcon: null, + memberCount: 1, + lastAssigned: null, + }, + ]; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findProjectAssignments.mockResolvedValue(mockProjects); + + const result = await roleService.getRoleAssignments('project:editor'); + + expect(roleRepository.findBySlug).toHaveBeenCalledWith('project:editor'); + expect(roleRepository.findProjectAssignments).toHaveBeenCalledWith('project:editor'); + expect(result).toEqual({ + projects: mockProjects, + totalProjects: 2, + }); + }); + + it('should throw NotFoundError when role does not exist', async () => { + roleRepository.findBySlug.mockResolvedValue(null); + + await expect(roleService.getRoleAssignments('nonexistent-role')).rejects.toThrow( + NotFoundError, + ); + await expect(roleService.getRoleAssignments('nonexistent-role')).rejects.toThrow( + 'Role not found', + ); + + expect(roleRepository.findProjectAssignments).not.toHaveBeenCalled(); + }); + + it('should return empty projects array when role has no assignments', async () => { + const mockRole = { + slug: 'project:custom-role', + displayName: 'Custom Role', + description: 'A custom role', + systemRole: false, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findProjectAssignments.mockResolvedValue([]); + + const result = await roleService.getRoleAssignments('project:custom-role'); + + expect(result).toEqual({ + projects: [], + totalProjects: 0, + }); + }); + + it('should set totalProjects to match the length of the projects array', async () => { + const mockRole = { + slug: 'project:viewer', + displayName: 'Viewer', + description: null, + systemRole: true, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + const mockProjects = [ + { + projectId: 'p-1', + projectName: 'Project 1', + projectIcon: null, + memberCount: 5, + lastAssigned: '2026-02-01T00:00:00.000Z', + }, + { + projectId: 'p-2', + projectName: 'Project 2', + projectIcon: null, + memberCount: 2, + lastAssigned: '2026-02-10T00:00:00.000Z', + }, + { + projectId: 'p-3', + projectName: 'Project 3', + projectIcon: { type: 'icon', value: 'folder' }, + memberCount: 1, + lastAssigned: null, + }, + ]; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findProjectAssignments.mockResolvedValue(mockProjects); + + const result = await roleService.getRoleAssignments('project:viewer'); + + expect(result.totalProjects).toBe(3); + expect(result.projects).toHaveLength(3); + }); + }); + + describe('getRoleProjectMembers', () => { + it('should return members for a valid role and project', async () => { + const mockRole = { + slug: 'project:editor', + displayName: 'Editor', + description: null, + systemRole: true, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + const mockMembers = [ + { + userId: 'user-1', + firstName: 'Alice', + lastName: 'Smith', + email: 'alice@example.com', + role: 'project:editor', + }, + { + userId: 'user-2', + firstName: 'Bob', + lastName: null, + email: 'bob@example.com', + role: 'project:editor', + }, + ]; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findAllProjectMembers.mockResolvedValue(mockMembers); + + const result = await roleService.getRoleProjectMembers('project:editor', 'project-123'); + + expect(roleRepository.findBySlug).toHaveBeenCalledWith('project:editor'); + expect(roleRepository.findAllProjectMembers).toHaveBeenCalledWith( + 'project-123', + 'project:editor', + ); + expect(result).toEqual({ members: mockMembers }); + }); + + it('should throw NotFoundError when role does not exist', async () => { + roleRepository.findBySlug.mockResolvedValue(null); + + await expect( + roleService.getRoleProjectMembers('nonexistent-role', 'project-123'), + ).rejects.toThrow(NotFoundError); + await expect( + roleService.getRoleProjectMembers('nonexistent-role', 'project-123'), + ).rejects.toThrow('Role not found'); + + expect(roleRepository.findAllProjectMembers).not.toHaveBeenCalled(); + }); + + it('should return empty members array when project has no members with the role', async () => { + const mockRole = { + slug: 'project:admin', + displayName: 'Admin', + description: null, + systemRole: true, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findAllProjectMembers.mockResolvedValue([]); + + const result = await roleService.getRoleProjectMembers('project:admin', 'project-456'); + + expect(result).toEqual({ members: [] }); + }); + + it('should pass the correct projectId and roleSlug to the repository', async () => { + const mockRole = { + slug: 'project:custom-abc123', + displayName: 'Custom Role', + description: 'A custom project role', + systemRole: false, + roleType: 'project' as const, + scopes: [], + projectRelations: [], + }; + + roleRepository.findBySlug.mockResolvedValue(mockRole as never); + roleRepository.findAllProjectMembers.mockResolvedValue([]); + + await roleService.getRoleProjectMembers('project:custom-abc123', 'specific-project-id'); + + expect(roleRepository.findBySlug).toHaveBeenCalledWith('project:custom-abc123'); + expect(roleRepository.findAllProjectMembers).toHaveBeenCalledWith( + 'specific-project-id', + 'project:custom-abc123', + ); + }); + }); +}); diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index accd3281899..76739d7e42d 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -1,3 +1,4 @@ +import type { RoleAssignmentsResponse, RoleProjectMembersResponse } from '@n8n/api-types'; import { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; import { @@ -49,12 +50,13 @@ export class RoleService { private readonly logger: Logger, ) {} - private dbRoleToRoleDTO(role: Role, usedByUsers?: number): RoleDTO { + private dbRoleToRoleDTO(role: Role, usedByUsers?: number, usedByProjects?: number): RoleDTO { return { ...role, scopes: role.scopes.map((s) => s.slug), licensed: this.isRoleLicensed(role.slug), usedByUsers, + usedByProjects, }; } @@ -65,25 +67,62 @@ export class RoleService { return roles.map((r) => this.dbRoleToRoleDTO(r)); } - const roleCounts = await this.roleRepository.findAllRoleCounts(); + const [roleCounts, projectCounts] = await Promise.all([ + this.roleRepository.findAllRoleCounts(), + this.roleRepository.findAllProjectCounts(), + ]); return roles.map((role) => { const usedByUsers = roleCounts[role.slug] ?? 0; - return this.dbRoleToRoleDTO(role, usedByUsers); + const usedByProjects = projectCounts[role.slug] ?? 0; + return this.dbRoleToRoleDTO(role, usedByUsers, usedByProjects); }); } async getRole(slug: string, withCount: boolean = false): Promise { const role = await this.roleRepository.findBySlug(slug); if (role) { - const usedByUsers = withCount - ? await this.roleRepository.countUsersWithRole(role) - : undefined; - return this.dbRoleToRoleDTO(role, usedByUsers); + let usedByUsers: number | undefined; + let usedByProjects: number | undefined; + if (withCount) { + const [userCount, projectCounts] = await Promise.all([ + this.roleRepository.countUsersWithRole(role), + this.roleRepository.findAllProjectCounts(), + ]); + usedByUsers = userCount; + usedByProjects = projectCounts[role.slug] ?? 0; + } + return this.dbRoleToRoleDTO(role, usedByUsers, usedByProjects); } throw new NotFoundError('Role not found'); } + async getRoleAssignments(slug: string): Promise { + const role = await this.roleRepository.findBySlug(slug); + if (!role) { + throw new NotFoundError('Role not found'); + } + + const projects = await this.roleRepository.findProjectAssignments(role.slug); + return { + projects, + totalProjects: projects.length, + }; + } + + async getRoleProjectMembers( + slug: string, + projectId: string, + ): Promise { + const role = await this.roleRepository.findBySlug(slug); + if (!role) { + throw new NotFoundError('Role not found'); + } + + const members = await this.roleRepository.findAllProjectMembers(projectId, role.slug); + return { members }; + } + async removeCustomRole(slug: string) { const role = await this.roleRepository.findBySlug(slug); if (!role) { diff --git a/packages/cli/test/integration/controllers/role.controller-db.test.ts b/packages/cli/test/integration/controllers/role.controller-db.test.ts index b5bb3010f5b..6d811a634a3 100644 --- a/packages/cli/test/integration/controllers/role.controller-db.test.ts +++ b/packages/cli/test/integration/controllers/role.controller-db.test.ts @@ -1,5 +1,5 @@ import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; -import { testDb } from '@n8n/backend-test-utils'; +import { createTeamProject, linkUserToProject, testDb } from '@n8n/backend-test-utils'; import { PROJECT_ADMIN_ROLE, PROJECT_EDITOR_ROLE, @@ -7,6 +7,7 @@ import { PROJECT_VIEWER_ROLE, RoleRepository, } from '@n8n/db'; +import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; import { cleanupRolesAndScopes } from '../shared/db/roles'; @@ -18,11 +19,13 @@ describe('RoleController - Integration Tests', () => { const testServer = setupTestServer({ endpointGroups: ['role'] }); let ownerAgent: SuperAgentTest; let memberAgent: SuperAgentTest; + let owner: User; + let member: User; beforeAll(async () => { await testDb.init(); - const owner = await createOwner(); - const member = await createMember(); + owner = await createOwner(); + member = await createMember(); ownerAgent = testServer.authAgentFor(owner); memberAgent = testServer.authAgentFor(member); }); @@ -186,4 +189,65 @@ describe('RoleController - Integration Tests', () => { }); }); }); + + describe('GET /roles/:slug/assignments', () => { + it('should return projects where the role is assigned', async () => { + const project = await createTeamProject('Test Project', owner); + await linkUserToProject(member, project, 'project:editor'); + + const response = await ownerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`) + .expect(200); + + expect(response.body.data.totalProjects).toBeGreaterThanOrEqual(1); + const projectNames = response.body.data.projects.map( + (p: { projectName: string }) => p.projectName, + ); + expect(projectNames).toContain('Test Project'); + + const testProject = response.body.data.projects.find( + (p: { projectName: string }) => p.projectName === 'Test Project', + ); + expect(testProject.memberCount).toBe(1); + expect(testProject.projectId).toBe(project.id); + }); + + it('should return empty when role has no assignments', async () => { + const response = await ownerAgent + .get(`/roles/${PROJECT_VIEWER_ROLE.slug}/assignments`) + .expect(200); + + expect(response.body.data.totalProjects).toBe(0); + expect(response.body.data.projects).toEqual([]); + }); + + it('should require role:manage scope (deny member)', async () => { + await memberAgent.get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`).expect(403); + }); + }); + + describe('GET /roles/:slug/assignments/:projectId/members', () => { + it('should return only members with the specified role', async () => { + const project = await createTeamProject('Members Test', owner); + await linkUserToProject(member, project, 'project:editor'); + // owner is project:admin via createTeamProject + + const response = await ownerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${project.id}/members`) + .expect(200); + + // Should only include the editor, not the admin + expect(response.body.data.members).toHaveLength(1); + expect(response.body.data.members[0].email).toBe(member.email); + expect(response.body.data.members[0].role).toBe('project:editor'); + }); + + it('should require role:manage scope (deny member)', async () => { + const project = await createTeamProject('Auth Test'); + + await memberAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${project.id}/members`) + .expect(403); + }); + }); }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 146d42f3fdf..148013893a3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2455,7 +2455,9 @@ "projectRoles.action.delete.title": "Delete '{roleName}'?", "projectRoles.action.delete.text": "Are you sure that you want to delete '{roleName}' permanently? This action cannot be undone.", "projectRoles.action.delete.warning": "Deleting a role is permanent. This can’t be undone.", - "projectRoles.action.delete.useWarning": "You can’t delete this role while it’s assigned to {count} users. Unassign it from all users first.", + "projectRoles.action.delete.useWarning.before": "You can't delete this role while it's assigned in", + "projectRoles.action.delete.useWarning.linkText": "{count} project | {count} projects", + "projectRoles.action.delete.useWarning.after": "To delete it, first reassign users within projects to a different role.", "projectRoles.dangerZone": "Danger zone", "projectRoles.action.duplicate": "Duplicate", "projectRoles.action.duplicate.name": "Copy of {roleName}", @@ -2468,6 +2470,19 @@ "projectRoles.action.update.error": "Error updating role", "projectRoles.action.update.title": "Update role for '{count}' users?", "projectRoles.action.update.text": "These changes will instantly affect all {count} users with this role. Are you sure you want to continue?", + "projectRoles.tab.permissions": "Permissions", + "projectRoles.tab.assignments": "Assignments", + "projectRoles.sourceControl.table.projectsAssigned": "Projects assigned", + "projectRoles.assignments.emptyState": "This role is not assigned in any projects yet.", + "projectRoles.assignments.projectColumn": "Project", + "projectRoles.assignments.membersColumn": "Members assigned", + "projectRoles.assignments.lastAssignedColumn": "Last assigned", + "projectRoles.assignments.membersModal.title": "\"{projectName}\" members", + "projectRoles.assignments.membersModal.manageMembers": "Manage members", + "projectRoles.assignments.membersModal.cancel": "Cancel", + "projectRoles.action.delete.inUse.title": "{roleName}", + "projectRoles.action.delete.inUse.goToAssignments": "Go to assignments", + "projectRoles.systemRoleNotEditable": "System roles are not editable", "renameAction.emptyName.message": "Please enter a name, or press 'esc' to go back to the old one", "renameAction.emptyName.title": "Name missing", "renameAction.invalidName.title": "Invalid name", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/roles.ts b/packages/frontend/@n8n/rest-api-client/src/api/roles.ts index b3a9b7a65e5..2cffe21fdcb 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/roles.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/roles.ts @@ -1,4 +1,9 @@ -import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; +import type { + CreateRoleDto, + RoleAssignmentsResponse, + RoleProjectMembersResponse, + UpdateRoleDto, +} from '@n8n/api-types'; import type { AllRolesMap, Role } from '@n8n/permissions'; import type { IRestApiContext } from '../types'; @@ -33,3 +38,22 @@ export const updateProjectRole = async ( export const deleteProjectRole = async (context: IRestApiContext, slug: string): Promise => { return await makeRestApiRequest(context, 'DELETE', `/roles/${slug}`); }; + +export const getRoleAssignments = async ( + context: IRestApiContext, + slug: string, +): Promise => { + return await makeRestApiRequest(context, 'GET', `/roles/${slug}/assignments`); +}; + +export const getRoleProjectMembers = async ( + context: IRestApiContext, + slug: string, + projectId: string, +): Promise => { + return await makeRestApiRequest( + context, + 'GET', + `/roles/${slug}/assignments/${projectId}/members`, + ); +}; diff --git a/packages/frontend/editor-ui/src/app/stores/roles.store.ts b/packages/frontend/editor-ui/src/app/stores/roles.store.ts index 73cd3bd4616..0805013ec79 100644 --- a/packages/frontend/editor-ui/src/app/stores/roles.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/roles.store.ts @@ -8,7 +8,12 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import * as rolesApi from '@n8n/rest-api-client/api/roles'; import { useRootStore } from '@n8n/stores/useRootStore'; -import type { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; +import type { + CreateRoleDto, + RoleAssignmentsResponse, + RoleProjectMembersResponse, + UpdateRoleDto, +} from '@n8n/api-types'; import { useSettingsStore } from './settings.store'; export const useRolesStore = defineStore('roles', () => { @@ -72,6 +77,17 @@ export const useRolesStore = defineStore('roles', () => { return await rolesApi.updateProjectRole(rootStore.restApiContext, slug, body); }; + const fetchRoleAssignments = async (slug: string): Promise => { + return await rolesApi.getRoleAssignments(rootStore.restApiContext, slug); + }; + + const fetchRoleProjectMembers = async ( + slug: string, + projectId: string, + ): Promise => { + return await rolesApi.getRoleProjectMembers(rootStore.restApiContext, slug, projectId); + }; + return { roles, processedProjectRoles, @@ -82,5 +98,7 @@ export const useRolesStore = defineStore('roles', () => { fetchRoleBySlug, updateProjectRole, deleteProjectRole, + fetchRoleAssignments, + fetchRoleProjectMembers, }; }); diff --git a/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.test.ts b/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.test.ts index 5935fbb9051..5981a405eff 100644 --- a/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.test.ts +++ b/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.test.ts @@ -185,7 +185,6 @@ describe('ProjectRoleView', () => { it('should render permissions section with scope types', () => { const { getByText } = renderComponent(); - expect(getByText('Permissions')).toBeInTheDocument(); expect(getByText('Preset')).toBeInTheDocument(); expect(getByText('Admin')).toBeInTheDocument(); expect(getByText('Editor')).toBeInTheDocument(); diff --git a/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.vue b/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.vue index e2e0b7f0d29..d5f3da06a4f 100644 --- a/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.vue +++ b/packages/frontend/editor-ui/src/features/project-roles/ProjectRoleView.vue @@ -8,20 +8,25 @@ import { N8nButton, N8nFormInput, N8nHeading, + N8nInput, N8nLoading, + N8nTabs, N8nText, N8nTooltip, } from '@n8n/design-system'; +import type { TabOptions } from '@n8n/design-system'; import { useI18n } from '@n8n/i18n'; import type { Role } from '@n8n/permissions'; import { useSettingsStore } from '@/app/stores/settings.store'; import { useAsyncState } from '@vueuse/core'; import isEqual from 'lodash/isEqual'; import sortBy from 'lodash/sortBy'; -import { computed, ref, toRaw } from 'vue'; +import { computed, ref, toRaw, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { SCOPE_TYPES, SCOPES } from './projectRoleScopes'; +import RoleAssignmentsTab from './RoleAssignmentsTab.vue'; + const rolesStore = useRolesStore(); const route = useRoute(); const router = useRouter(); @@ -33,6 +38,17 @@ const settingsStore = useSettingsStore(); const props = defineProps<{ roleSlug?: string }>(); +const activeTab = ref((route.query?.tab as string) ?? 'permissions'); + +watch(activeTab, (newTab) => { + void router.replace({ query: { ...route.query, tab: newTab } }); +}); + +const tabOptions = computed>>(() => [ + { label: i18n.baseText('projectRoles.tab.permissions'), value: 'permissions' }, + { label: i18n.baseText('projectRoles.tab.assignments'), value: 'assignments' }, +]); + // Dynamic back button text and navigation based on where the user navigated from const cameFromProjectSettings = computed(() => route.query.from === VIEWS.PROJECT_SETTINGS); @@ -322,7 +338,7 @@ const displayNameValidationRules = [ {{ roleSlug ? `Role "${form.displayName}"` : i18n.baseText('projectRoles.newRole') }} -
+
{{ i18n.baseText('projectRoles.discardChanges') }} @@ -330,111 +346,154 @@ const displayNameValidationRules = [ {{ i18n.baseText('projectRoles.save') }}
- +