feat: Add assignments tab for custom roles with project visibility (#25992)

Co-authored-by: Declan Carroll <declan@n8n.io>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: Ricardo Espinoza <ricardo@n8n.io>
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Ali Elkhateeb <ali.elkhateeb@n8n.io>
Co-authored-by: Matsu <huhta.matias@gmail.com>
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Rob Hough <robhough180@gmail.com>
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
Co-authored-by: James Gee <1285296+geemanjs@users.noreply.github.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
Co-authored-by: Stephen Wright <sjw948@gmail.com>
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
Co-authored-by: Albert Alises <albert.alises@gmail.com>
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Daria <daria.staferova@n8n.io>
Co-authored-by: peteawood <pete.a.wood@gmail.com>
Co-authored-by: Roman Davydchuk <roman.davydchuk@n8n.io>
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eugene <eugene@n8n.io>
Co-authored-by: Benjamin Schroth <benjamin@n8n.io>
Co-authored-by: Sandra Zollner <sandra.zollner@n8n.io>
Co-authored-by: Irénée <irenee.ajeneza@n8n.io>
Co-authored-by: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com>
Co-authored-by: oleg <me@olegivaniv.com>
Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Romeo Balta <7095569+romeobalta@users.noreply.github.com>
Co-authored-by: Tuukka Kantola <Tuukkaa@users.noreply.github.com>
Co-authored-by: Kaito Horiuchi <horiyee.ka@gmail.com>
Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Csaba Tuncsik
2026-02-27 10:33:41 +00:00
committed by GitHub
co-authored by Declan Carroll Milorad FIlipović Ricardo Espinoza Michael Kret Charlie Kolb Mutasem Aldmour Ali Elkhateeb Matsu Guillaume Jacquart yehorkardash Rob Hough Svetoslav Dekov James Gee Alex Grozav Dawid Myslak Stephen Wright Andreas Fitzek Albert Alises Dimitri Lavrenük Danny Martini Daria peteawood Roman Davydchuk Jaakko Husso Elias Meire Cursor Eugene Benjamin Schroth Sandra Zollner Irénée Benjamin Schroth oleg Konstantin Tieber Nikhil Kuriakose Romeo Balta Tuukka Kantola Kaito Horiuchi Michael Drury Claude Opus 4.6
parent 520ff6c1c9
commit c93e4380bc
19 changed files with 1497 additions and 122 deletions
+10
View File
@@ -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';
@@ -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<typeof roleProjectAssignmentSchema>;
export class RoleAssignmentsResponseDto extends Z.class({
projects: z.array(roleProjectAssignmentSchema),
totalProjects: z.number(),
}) {}
export type RoleAssignmentsResponse = InstanceType<typeof RoleAssignmentsResponseDto>;
@@ -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<typeof roleProjectMemberSchema>;
export class RoleProjectMembersResponseDto extends Z.class({
members: z.array(roleProjectMemberSchema),
}) {}
export type RoleProjectMembersResponse = InstanceType<typeof RoleProjectMembersResponseDto>;
@@ -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<Role> {
@@ -60,6 +60,101 @@ export class RoleRepository extends Repository<Role> {
);
}
async findAllProjectCounts(): Promise<Record<string, number>> {
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<string, number>,
);
}
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 },
@@ -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<typeof roleSchema>;
@@ -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<RoleProjectMembersResponse> {
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<RoleAssignmentsResponse> {
const result = await this.roleService.getRoleAssignments(slug);
return RoleAssignmentsResponseDto.parse(result);
}
@Get('/:slug')
async getRoleBySlug(
_req: AuthenticatedRequest,
@@ -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<LicenseState>();
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',
);
});
});
});
+46 -7
View File
@@ -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<RoleDTO> {
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<RoleAssignmentsResponse> {
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<RoleProjectMembersResponse> {
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) {
@@ -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);
});
});
});
@@ -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",
@@ -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<Role> => {
return await makeRestApiRequest(context, 'DELETE', `/roles/${slug}`);
};
export const getRoleAssignments = async (
context: IRestApiContext,
slug: string,
): Promise<RoleAssignmentsResponse> => {
return await makeRestApiRequest(context, 'GET', `/roles/${slug}/assignments`);
};
export const getRoleProjectMembers = async (
context: IRestApiContext,
slug: string,
projectId: string,
): Promise<RoleProjectMembersResponse> => {
return await makeRestApiRequest(
context,
'GET',
`/roles/${slug}/assignments/${projectId}/members`,
);
};
@@ -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<RoleAssignmentsResponse> => {
return await rolesApi.getRoleAssignments(rootStore.restApiContext, slug);
};
const fetchRoleProjectMembers = async (
slug: string,
projectId: string,
): Promise<RoleProjectMembersResponse> => {
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,
};
});
@@ -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();
@@ -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<string>((route.query?.tab as string) ?? 'permissions');
watch(activeTab, (newTab) => {
void router.replace({ query: { ...route.query, tab: newTab } });
});
const tabOptions = computed<Array<TabOptions<string>>>(() => [
{ 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 = [
<N8nHeading tag="h1" size="2xlarge">
{{ roleSlug ? `Role "${form.displayName}"` : i18n.baseText('projectRoles.newRole') }}
</N8nHeading>
<div v-if="initialState && !isReadOnly" :class="$style.headerActions">
<div v-if="initialState && !isReadOnly && !isLoading" :class="$style.headerActions">
<N8nButton variant="subtle" :disabled="!hasUnsavedChanges" @click="resetForm(initialState)">
{{ i18n.baseText('projectRoles.discardChanges') }}
</N8nButton>
@@ -330,111 +346,154 @@ const displayNameValidationRules = [
{{ i18n.baseText('projectRoles.save') }}
</N8nButton>
</div>
<template v-else-if="!initialState">
<template v-else-if="!roleSlug">
<N8nButton @click="handleSubmit">{{ i18n.baseText('projectRoles.create') }}</N8nButton>
</template>
</div>
<div class="mb-l" :class="$style.formContainer">
<N8nFormInput
v-model="form.displayName"
:label="i18n.baseText('projectRoles.roleName')"
validate-on-blur
:validation-rules="displayNameValidationRules"
class="mb-s"
show-required-asterisk
required
:maxlength="100"
:disabled="isReadOnly"
></N8nFormInput>
<N8nFormInput
v-model="form.description"
:label="i18n.baseText('projectRoles.description')"
:placeholder="i18n.baseText('projectRoles.optional')"
type="textarea"
:maxlength="500"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="isReadOnly"
></N8nFormInput>
<!-- Read-only: use slot to wrap input with tooltip -->
<template v-if="isReadOnly">
<N8nFormInput
v-model="form.displayName"
:label="i18n.baseText('projectRoles.roleName')"
class="mb-s"
show-required-asterisk
required
>
<N8nTooltip
:content="i18n.baseText('projectRoles.systemRoleNotEditable')"
placement="top"
>
<N8nInput v-model="form.displayName" :maxlength="100" disabled />
</N8nTooltip>
</N8nFormInput>
<N8nFormInput v-model="form.description" :label="i18n.baseText('projectRoles.description')">
<N8nTooltip
:content="i18n.baseText('projectRoles.systemRoleNotEditable')"
placement="top"
>
<N8nInput
v-model="form.description"
type="textarea"
:placeholder="i18n.baseText('projectRoles.optional')"
:maxlength="500"
:autosize="{ minRows: 2, maxRows: 4 }"
disabled
/>
</N8nTooltip>
</N8nFormInput>
</template>
<!-- Editable: standard N8nFormInput with full validation -->
<template v-else>
<N8nFormInput
v-model="form.displayName"
:label="i18n.baseText('projectRoles.roleName')"
validate-on-blur
:validation-rules="displayNameValidationRules"
class="mb-s"
show-required-asterisk
required
:maxlength="100"
/>
<N8nFormInput
v-model="form.description"
:label="i18n.baseText('projectRoles.description')"
:placeholder="i18n.baseText('projectRoles.optional')"
type="textarea"
:maxlength="500"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
</template>
</div>
<N8nHeading tag="h2" size="xlarge" class="mb-s">
{{ i18n.baseText('projectRoles.permissions') }}
</N8nHeading>
<template v-if="!isReadOnly">
<N8nText color="text-light" class="mb-2xs" tag="p">
{{ i18n.baseText('projectRoles.preset') }}
</N8nText>
<div v-if="roleSlug" class="mb-l">
<N8nTabs v-model="activeTab" :options="tabOptions" />
</div>
<div class="mb-s" :class="$style.presetsContainer">
<N8nButton variant="subtle" @click="setPreset('project:admin')">
{{ i18n.baseText('projectRoles.admin') }}
</N8nButton>
<N8nButton variant="subtle" @click="setPreset('project:editor')">
{{ i18n.baseText('projectRoles.editor') }}
</N8nButton>
<N8nButton variant="subtle" @click="setPreset('project:viewer')">
{{ i18n.baseText('projectRoles.viewer') }}
</N8nButton>
</div>
</template>
<div v-show="!roleSlug || activeTab === 'permissions'">
<template v-if="!isReadOnly">
<N8nText color="text-light" class="mb-2xs" tag="p">
{{ i18n.baseText('projectRoles.preset') }}
</N8nText>
<div :class="$style.cardContainer">
<div v-for="type in scopeTypes" :key="type" class="mb-s mt-s" :class="$style.card">
<div :class="$style.cardTitle">
{{ i18n.baseText(`projectRoles.type.${type}`) }}
<div class="mb-s" :class="$style.presetsContainer">
<N8nButton variant="subtle" @click="setPreset('project:admin')">
{{ i18n.baseText('projectRoles.admin') }}
</N8nButton>
<N8nButton variant="subtle" @click="setPreset('project:editor')">
{{ i18n.baseText('projectRoles.editor') }}
</N8nButton>
<N8nButton variant="subtle" @click="setPreset('project:viewer')">
{{ i18n.baseText('projectRoles.viewer') }}
</N8nButton>
</div>
<div style="flex: 1">
<N8nLoading v-if="isLoading" :rows="scopes[type].length" :shrink-last="false" />
<template v-else>
<div v-for="scope in scopes[type]" :key="scope" class="mb-2xs">
<N8nTooltip
:content="i18n.baseText(`projectRoles.${scope}.tooltip`)"
placement="right"
:enterable="false"
:show-after="250"
>
<N8nFormInput
:data-test-id="`scope-checkbox-${scope}`"
:model-value="form.scopes.includes(scope)"
:label="i18n.baseText(`projectRoles.${scope}`)"
validate-on-blur
type="checkbox"
:class="$style.checkbox"
:disabled="isReadOnly"
@update:model-value="() => toggleScope(scope)"
/>
</N8nTooltip>
</div>
</template>
<div :class="$style.cardContainer">
<div v-for="type in scopeTypes" :key="type" class="mb-s mt-s" :class="$style.card">
<div :class="$style.cardTitle">
{{ i18n.baseText(`projectRoles.type.${type}`) }}
</div>
<div style="flex: 1">
<N8nLoading v-if="isLoading" :rows="scopes[type].length" :shrink-last="false" />
<template v-else>
<div v-for="scope in scopes[type]" :key="scope" class="mb-2xs">
<N8nTooltip
:content="i18n.baseText(`projectRoles.${scope}.tooltip`)"
placement="right"
:enterable="false"
:show-after="250"
>
<N8nFormInput
:data-test-id="`scope-checkbox-${scope}`"
:model-value="form.scopes.includes(scope)"
:label="i18n.baseText(`projectRoles.${scope}`)"
validate-on-blur
type="checkbox"
:class="$style.checkbox"
:disabled="isReadOnly"
@update:model-value="() => toggleScope(scope)"
/>
</N8nTooltip>
</div>
</template>
</div>
</div>
</div>
<div v-if="roleSlug && !isReadOnly" class="mt-xl">
<N8nHeading tag="h2" class="mb-2xs" size="large">
{{ i18n.baseText('projectRoles.dangerZone') }}
</N8nHeading>
<N8nText tag="p" class="mb-s">
<template v-if="initialState?.usedByProjects">
{{ i18n.baseText('projectRoles.action.delete.useWarning.before') }}
<a :class="$style.assignmentsLink" @click="activeTab = 'assignments'">
{{
i18n.baseText('projectRoles.action.delete.useWarning.linkText', {
adjustToNumber: initialState.usedByProjects,
interpolate: { count: initialState.usedByProjects },
})
}} </a
>.
{{ i18n.baseText('projectRoles.action.delete.useWarning.after') }}
</template>
</div>
<template v-else>
{{ i18n.baseText('projectRoles.action.delete.warning') }}
</template>
</N8nText>
<N8nButton
variant="destructive"
:disabled="Boolean(initialState?.usedByProjects)"
@click="deleteRole"
>
{{ i18n.baseText('projectRoles.action.delete.button') }}
</N8nButton>
</div>
</div>
<div v-if="roleSlug && !isReadOnly" class="mt-xl">
<N8nHeading tag="h2" class="mb-2xs" size="large">
{{ i18n.baseText('projectRoles.dangerZone') }}
</N8nHeading>
<N8nText tag="p" class="mb-s">
<template v-if="initialState?.usedByUsers">
{{
i18n.baseText('projectRoles.action.delete.useWarning', {
interpolate: {
count: initialState.usedByUsers,
},
})
}}
</template>
<template v-else> {{ i18n.baseText('projectRoles.action.delete.warning') }}</template>
</N8nText>
<N8nButton
variant="destructive"
:disabled="Boolean(initialState?.usedByUsers)"
@click="deleteRole"
>
{{ i18n.baseText('projectRoles.action.delete.button') }}
</N8nButton>
</div>
<RoleAssignmentsTab v-if="roleSlug && activeTab === 'assignments'" :role-slug="roleSlug" />
</div>
</template>
@@ -504,4 +563,14 @@ const displayNameValidationRules = [
padding-bottom: 0 !important;
}
}
.assignmentsLink {
color: var(--color--text);
cursor: pointer;
text-decoration: underline;
}
.assignmentsLink:hover {
color: var(--color--primary);
}
</style>
@@ -136,7 +136,7 @@ describe('ProjectRolesView', () => {
expect(getByText('Name')).toBeInTheDocument();
expect(getByText('Type')).toBeInTheDocument();
expect(getByText('Assigned to')).toBeInTheDocument();
expect(getByText('Projects assigned')).toBeInTheDocument();
expect(getByText('Last edited')).toBeInTheDocument();
});
@@ -22,7 +22,7 @@ import { useI18n } from '@n8n/i18n';
import type { Role } from '@n8n/permissions';
import dateformat from 'dateformat';
import { onMounted, ref, useCssModule } from 'vue';
import { useRouter } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
const { showError, showMessage } = useToast();
@@ -54,12 +54,12 @@ const headers = ref<Array<TableHeader<Role>>>([
resize: false,
},
{
title: i18n.baseText('projectRoles.sourceControl.table.assignedTo'),
key: 'usedByUsers',
title: i18n.baseText('projectRoles.sourceControl.table.projectsAssigned'),
key: 'usedByProjects',
disableSort: true,
align: 'end',
value: (item: Role) => item.usedByUsers ?? 0,
width: 75,
value: (item: Role) => item.usedByProjects ?? 0,
width: 120,
resize: false,
},
{
@@ -83,11 +83,43 @@ const headers = ref<Array<TableHeader<Role>>>([
]);
async function deleteRole(item: Role) {
i18n.baseText('projectRoles.action.delete.text', {
interpolate: {
roleName: item.displayName,
},
});
// When role is in use, show "Go to assignments" dialog instead of delete confirmation
if (item.usedByProjects && item.usedByProjects > 0) {
const inUseText =
[
i18n.baseText('projectRoles.action.delete.useWarning.before'),
i18n.baseText('projectRoles.action.delete.useWarning.linkText', {
adjustToNumber: item.usedByProjects,
interpolate: { count: item.usedByProjects },
}),
].join(' ') +
'. ' +
i18n.baseText('projectRoles.action.delete.useWarning.after');
const goToAssignments = await message.confirm(
inUseText,
i18n.baseText('projectRoles.action.delete.inUse.title', {
interpolate: {
roleName: item.displayName,
},
}),
{
type: 'warning',
confirmButtonText: i18n.baseText('projectRoles.action.delete.inUse.goToAssignments'),
cancelButtonText: i18n.baseText('projectRoles.action.cancel'),
},
);
if (goToAssignments === MODAL_CONFIRM) {
void router.push({
name: item.systemRole ? VIEWS.PROJECT_ROLE_VIEW : VIEWS.PROJECT_ROLE_SETTINGS,
params: { roleSlug: item.slug },
query: { tab: 'assignments' },
});
}
return;
}
const deleteConfirmed = await message.confirm(
i18n.baseText('projectRoles.action.delete.text', {
interpolate: {
@@ -178,7 +210,7 @@ function rowProps(_row: Role) {
}
function rowActions(
item: Role,
_item: Role,
): Array<{ label: string; value: keyof typeof actions; disabled?: boolean }> {
return [
{
@@ -188,7 +220,6 @@ function rowActions(
{
label: i18n.baseText('projectRoles.action.delete'),
value: 'delete',
disabled: item.usedByUsers !== 0,
},
];
}
@@ -262,6 +293,21 @@ function addRole() {
<N8nIcon icon="user-pen" /> {{ i18n.baseText('projectRoles.literal.custom') }}</template
>
</template>
<template #[`item.usedByProjects`]="{ item }">
<RouterLink
v-if="(item.usedByProjects ?? 0) > 0"
:to="{
name: item.systemRole ? VIEWS.PROJECT_ROLE_VIEW : VIEWS.PROJECT_ROLE_SETTINGS,
params: { roleSlug: item.slug },
query: { tab: 'assignments' },
}"
:class="$style.projectCountLink"
@click.stop
>
{{ item.usedByProjects }}
</RouterLink>
<template v-else>0</template>
</template>
<template #[`item.actions`]="{ item }">
<N8nActionToggle
v-if="!item.systemRole"
@@ -294,4 +340,14 @@ function addRole() {
.tallRow {
height: 64px;
}
.projectCountLink {
color: var(--color--text);
text-decoration: underline;
font-weight: var(--font-weight--bold);
}
.projectCountLink:hover {
color: var(--color--primary);
}
</style>
@@ -0,0 +1,333 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/vue';
import { useRolesStore } from '@/app/stores/roles.store';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import RoleAssignmentsTab from './RoleAssignmentsTab.vue';
import type { RoleAssignmentsResponse, RoleProjectAssignment } from '@n8n/api-types';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
return {
...actual,
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
}),
useRoute: () => ({
params: {},
query: {},
}),
};
});
const renderComponent = createComponentRenderer(RoleAssignmentsTab);
function createProject(overrides: Partial<RoleProjectAssignment> = {}): RoleProjectAssignment {
return {
projectId: 'proj-1',
projectName: 'My Project',
projectIcon: null,
memberCount: 3,
lastAssigned: '2025-06-15T10:00:00.000Z',
...overrides,
};
}
function createAssignmentsResponse(
projects: RoleProjectAssignment[] = [],
): RoleAssignmentsResponse {
return { projects, totalProjects: projects.length } as RoleAssignmentsResponse;
}
let rolesStore: MockedStore<typeof useRolesStore>;
describe('RoleAssignmentsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia();
rolesStore = mockedStore(useRolesStore);
});
describe('Loading state', () => {
it('should show loading skeleton while fetching assignments', () => {
rolesStore.fetchRoleAssignments.mockReturnValue(new Promise(() => {}));
const { container } = renderComponent({ props: { roleSlug: 'test-role' } });
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
});
});
describe('Empty state', () => {
it('should show empty message when no projects are assigned', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([]));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('This role is not assigned in any projects yet.')).toBeInTheDocument();
});
});
});
describe('Project listing', () => {
const projectAlpha = createProject({
projectId: 'proj-alpha',
projectName: 'Alpha Project',
memberCount: 5,
lastAssigned: '2025-03-10T12:00:00.000Z',
});
const projectBeta = createProject({
projectId: 'proj-beta',
projectName: 'Beta Project',
memberCount: 12,
lastAssigned: '2025-06-20T08:00:00.000Z',
});
const projectGamma = createProject({
projectId: 'proj-gamma',
projectName: 'Gamma Project',
memberCount: 1,
lastAssigned: null,
});
it('should show project names after loading', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(
createAssignmentsResponse([projectAlpha, projectBeta]),
);
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('Alpha Project')).toBeInTheDocument();
expect(getByText('Beta Project')).toBeInTheDocument();
});
});
it('should show member counts for each project', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(
createAssignmentsResponse([projectAlpha, projectBeta]),
);
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('5')).toBeInTheDocument();
expect(getByText('12')).toBeInTheDocument();
});
});
it('should show formatted dates for last assigned column', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([projectAlpha]));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('Mar 10th, 2025')).toBeInTheDocument();
});
});
it('should show em dash when lastAssigned is null', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([projectGamma]));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('\u2014')).toBeInTheDocument();
});
});
it('should show table column headers', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([projectAlpha]));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText(/^Project/)).toBeInTheDocument();
expect(getByText(/Members assigned/)).toBeInTheDocument();
expect(getByText(/Last assigned/)).toBeInTheDocument();
});
});
});
describe('Sorting', () => {
const projectLow = createProject({
projectId: 'proj-low',
projectName: 'Zebra Project',
memberCount: 1,
lastAssigned: '2025-01-01T00:00:00.000Z',
});
const projectMid = createProject({
projectId: 'proj-mid',
projectName: 'Middle Project',
memberCount: 5,
lastAssigned: '2025-06-15T00:00:00.000Z',
});
const projectHigh = createProject({
projectId: 'proj-high',
projectName: 'Alpha Project',
memberCount: 10,
lastAssigned: '2025-12-01T00:00:00.000Z',
});
const allProjects = [projectLow, projectMid, projectHigh];
function getRowTexts(container: Element): string[] {
const rows = container.querySelectorAll('tbody tr');
return Array.from(rows).map((row) => {
const firstCell = row.querySelector('td');
return firstCell?.textContent?.trim() ?? '';
});
}
it('should default to memberCount descending sort', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { container } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
const rows = getRowTexts(container);
expect(rows).toEqual(['Alpha Project', 'Middle Project', 'Zebra Project']);
});
});
it('should show descending arrow on memberCount column by default', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
const membersHeader = getByText(/Members assigned/);
expect(membersHeader.textContent).toContain('\u2193');
});
});
it('should toggle memberCount to ascending when clicking the same column again', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { container, getByText } = renderComponent({
props: { roleSlug: 'test-role' },
});
await waitFor(() => {
expect(getByText(/Members assigned/)).toBeInTheDocument();
});
await userEvent.click(getByText(/Members assigned/));
await waitFor(() => {
const rows = getRowTexts(container);
expect(rows).toEqual(['Zebra Project', 'Middle Project', 'Alpha Project']);
});
});
it('should sort by projectName ascending when clicking project column', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { container, getByText } = renderComponent({
props: { roleSlug: 'test-role' },
});
await waitFor(() => {
expect(getByText(/^Project/)).toBeInTheDocument();
});
await userEvent.click(getByText(/^Project/));
await waitFor(() => {
const rows = getRowTexts(container);
expect(rows).toEqual(['Alpha Project', 'Middle Project', 'Zebra Project']);
});
});
it('should sort by lastAssigned ascending when clicking last assigned column', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { container, getByText } = renderComponent({
props: { roleSlug: 'test-role' },
});
await waitFor(() => {
expect(getByText(/Last assigned/)).toBeInTheDocument();
});
await userEvent.click(getByText(/Last assigned/));
await waitFor(() => {
const rows = getRowTexts(container);
expect(rows).toEqual(['Zebra Project', 'Middle Project', 'Alpha Project']);
});
});
it('should show ascending arrow after switching to a text column', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText(/^Project/)).toBeInTheDocument();
});
await userEvent.click(getByText(/^Project/));
await waitFor(() => {
const projectHeader = getByText(/^Project/);
expect(projectHeader.textContent).toContain('\u2191');
});
});
it('should not show sort arrow on inactive columns', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse(allProjects));
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
const projectHeader = getByText(/^Project$/);
expect(projectHeader.textContent).not.toContain('\u2191');
expect(projectHeader.textContent).not.toContain('\u2193');
});
});
});
describe('Member modal', () => {
it('should open members modal when clicking member count button', async () => {
const project = createProject({
projectId: 'proj-1',
projectName: 'Test Project',
memberCount: 7,
});
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([project]));
rolesStore.fetchRoleProjectMembers.mockResolvedValue({ members: [] });
const { getByText } = renderComponent({ props: { roleSlug: 'test-role' } });
await waitFor(() => {
expect(getByText('7')).toBeInTheDocument();
});
await userEvent.click(getByText('7'));
await waitFor(() => {
expect(rolesStore.fetchRoleProjectMembers).toHaveBeenCalledWith('test-role', 'proj-1');
});
});
});
describe('Store interaction', () => {
it('should call fetchRoleAssignments with the provided roleSlug', async () => {
rolesStore.fetchRoleAssignments.mockResolvedValue(createAssignmentsResponse([]));
renderComponent({ props: { roleSlug: 'custom-editor' } });
await waitFor(() => {
expect(rolesStore.fetchRoleAssignments).toHaveBeenCalledWith('custom-editor');
});
});
});
});
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { VIEWS } from '@/app/constants';
import { useRolesStore } from '@/app/stores/roles.store';
import { N8nLoading, N8nTableBase, N8nText } from '@n8n/design-system';
import type { RoleProjectAssignment } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useAsyncState } from '@vueuse/core';
import dateformat from 'dateformat';
import { computed, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import RoleProjectMembersModal from './RoleProjectMembersModal.vue';
const props = defineProps<{ roleSlug: string }>();
const rolesStore = useRolesStore();
const i18n = useI18n();
const {
state: assignments,
isLoading,
execute,
} = useAsyncState(async () => await rolesStore.fetchRoleAssignments(props.roleSlug), {
projects: [],
totalProjects: 0,
});
watch(
() => props.roleSlug,
async () => await execute(),
);
const membersModalOpen = ref(false);
const selectedProject = ref<RoleProjectAssignment | null>(null);
type SortColumn = 'projectName' | 'memberCount' | 'lastAssigned';
const sortColumn = ref<SortColumn>('memberCount');
const sortDirection = ref<'asc' | 'desc'>('desc');
const sortedProjects = computed(() => {
const projects = [...assignments.value.projects];
return projects.sort((a, b) => {
let cmp = 0;
if (sortColumn.value === 'projectName') {
cmp = a.projectName.localeCompare(b.projectName);
} else if (sortColumn.value === 'memberCount') {
cmp = a.memberCount - b.memberCount;
} else {
cmp = (a.lastAssigned ?? '').localeCompare(b.lastAssigned ?? '');
}
return sortDirection.value === 'desc' ? -cmp : cmp;
});
});
function toggleSort(column: SortColumn) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
} else {
sortColumn.value = column;
sortDirection.value = column === 'memberCount' ? 'desc' : 'asc';
}
}
function sortIndicator(column: SortColumn): string {
if (sortColumn.value !== column) return '';
return sortDirection.value === 'asc' ? ' ↑' : ' ↓';
}
function openMembersModal(project: RoleProjectAssignment) {
selectedProject.value = project;
membersModalOpen.value = true;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
return dateformat(new Date(dateStr), 'mmm dS, yyyy');
}
</script>
<template>
<div :class="$style.container">
<N8nLoading v-if="isLoading" :rows="3" />
<div v-else-if="assignments.projects.length === 0" :class="$style.emptyState">
<N8nText color="text-light">
{{ i18n.baseText('projectRoles.assignments.emptyState') }}
</N8nText>
</div>
<N8nTableBase v-else>
<thead>
<tr>
<th :class="$style.sortableHeader" @click="toggleSort('projectName')">
{{ i18n.baseText('projectRoles.assignments.projectColumn')
}}{{ sortIndicator('projectName') }}
</th>
<th
:class="[$style.alignRight, $style.sortableHeader]"
@click="toggleSort('memberCount')"
>
{{ i18n.baseText('projectRoles.assignments.membersColumn')
}}{{ sortIndicator('memberCount') }}
</th>
<th :class="$style.sortableHeader" @click="toggleSort('lastAssigned')">
{{ i18n.baseText('projectRoles.assignments.lastAssignedColumn')
}}{{ sortIndicator('lastAssigned') }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="project in sortedProjects" :key="project.projectId">
<td>
<RouterLink
:to="{
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId: project.projectId },
}"
:class="$style.projectLink"
>
{{ project.projectName }}
</RouterLink>
</td>
<td :class="$style.alignRight">
<button :class="$style.memberCountButton" @click="openMembersModal(project)">
{{ project.memberCount }}
</button>
</td>
<td>
{{ formatDate(project.lastAssigned) }}
</td>
</tr>
</tbody>
</N8nTableBase>
<RoleProjectMembersModal
v-if="selectedProject"
:open="membersModalOpen"
:role-slug="roleSlug"
:project-id="selectedProject.projectId"
:project-name="selectedProject.projectName"
@update:open="membersModalOpen = $event"
/>
</div>
</template>
<style lang="css" module>
.container {
margin-top: var(--spacing--sm);
}
.emptyState {
padding: var(--spacing--xl);
text-align: center;
}
.alignRight {
text-align: right !important;
}
.sortableHeader {
cursor: pointer;
user-select: none;
}
.projectLink {
color: var(--color--text);
text-decoration: underline;
}
.projectLink:hover {
color: var(--color--primary);
}
.memberCountButton {
background: none;
border: none;
color: var(--color--text);
text-decoration: underline;
cursor: pointer;
font-size: inherit;
padding: 0;
}
.memberCountButton:hover {
color: var(--color--primary);
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { VIEWS } from '@/app/constants';
import { useRolesStore } from '@/app/stores/roles.store';
import {
N8nButton,
N8nDialog,
N8nDialogFooter,
N8nDialogHeader,
N8nDialogTitle,
N8nLoading,
N8nText,
N8nUserInfo,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { RoleProjectMembersResponse } from '@n8n/api-types';
const props = defineProps<{
open: boolean;
roleSlug: string;
projectId: string;
projectName: string;
}>();
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const rolesStore = useRolesStore();
const i18n = useI18n();
const router = useRouter();
const membersData = ref<RoleProjectMembersResponse>({ members: [] });
const isLoading = ref(false);
watch(
() => props.open,
async (isOpen) => {
if (!isOpen) return;
isLoading.value = true;
try {
membersData.value = await rolesStore.fetchRoleProjectMembers(props.roleSlug, props.projectId);
} finally {
isLoading.value = false;
}
},
{ immediate: true },
);
const roleDisplayNameMap = computed(() => {
const allRoles = [
...rolesStore.roles.global,
...rolesStore.roles.project,
...rolesStore.roles.credential,
...rolesStore.roles.workflow,
];
return new Map(allRoles.map((r) => [r.slug, r.displayName]));
});
function getRoleDisplayName(slug: string): string {
return roleDisplayNameMap.value.get(slug) ?? slug;
}
function navigateToProjectSettings() {
emit('update:open', false);
void router.push({
name: VIEWS.PROJECT_SETTINGS,
params: { projectId: props.projectId },
});
}
</script>
<template>
<N8nDialog :open="open" size="medium" @update:open="emit('update:open', $event)">
<N8nDialogHeader>
<N8nDialogTitle>
{{
i18n.baseText('projectRoles.assignments.membersModal.title', {
interpolate: { projectName },
})
}}
</N8nDialogTitle>
</N8nDialogHeader>
<div :class="$style.content">
<N8nLoading v-if="isLoading" :rows="3" />
<div v-else :class="$style.memberList">
<div v-for="member in membersData.members" :key="member.userId" :class="$style.memberRow">
<N8nUserInfo
:first-name="member.firstName"
:last-name="member.lastName"
:email="member.email"
/>
<N8nText :class="$style.roleLabel" color="text-light" size="small">
{{ getRoleDisplayName(member.role) }}
</N8nText>
</div>
</div>
</div>
<N8nDialogFooter>
<N8nButton variant="subtle" @click="emit('update:open', false)">
{{ i18n.baseText('projectRoles.assignments.membersModal.cancel') }}
</N8nButton>
<N8nButton @click="navigateToProjectSettings">
{{ i18n.baseText('projectRoles.assignments.membersModal.manageMembers') }}
</N8nButton>
</N8nDialogFooter>
</N8nDialog>
</template>
<style lang="css" module>
.content {
margin-top: var(--spacing--sm);
max-height: 400px;
overflow-y: auto;
}
.memberList {
display: flex;
flex-direction: column;
}
.memberRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--xs) 0;
border-bottom: var(--border);
}
.memberRow:last-child {
border-bottom: none;
}
.roleLabel {
flex-shrink: 0;
margin-left: var(--spacing--sm);
}
</style>