feat(editor): Display custom roles in the project role dropdown (#18983)

Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
This commit is contained in:
Guillaume Jacquart
2025-09-01 11:18:22 +02:00
committed by GitHub
parent d0ffd6e659
commit bf198f8263
3 changed files with 57 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
import type { AllRolesMap } from '@n8n/permissions'; import { type AllRolesMap, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as rolesApi from '@n8n/rest-api-client/api/roles'; import * as rolesApi from '@n8n/rest-api-client/api/roles';
@@ -20,11 +20,11 @@ export const useRolesStore = defineStore('roles', () => {
const processedProjectRoles = computed<AllRolesMap['project']>(() => const processedProjectRoles = computed<AllRolesMap['project']>(() =>
roles.value.project roles.value.project
.filter((role) => projectRoleOrderMap.value.has(role.slug)) .filter((role) => role.slug !== PROJECT_OWNER_ROLE_SLUG)
.sort( .sort(
(a, b) => (a, b) =>
(projectRoleOrderMap.value.get(a.slug) ?? 0) - (projectRoleOrderMap.value.get(a.slug) ?? Number.MAX_SAFE_INTEGER) -
(projectRoleOrderMap.value.get(b.slug) ?? 0), (projectRoleOrderMap.value.get(b.slug) ?? Number.MAX_SAFE_INTEGER),
), ),
); );

View File

@@ -12,6 +12,7 @@ import { createProjectListItem } from '@/__tests__/data/projects';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import type { FrontendSettings } from '@n8n/api-types'; import type { FrontendSettings } from '@n8n/api-types';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { useRolesStore } from '@/stores/roles.store';
vi.mock('vue-router', () => { vi.mock('vue-router', () => {
const params = {}; const params = {};
@@ -40,6 +41,7 @@ let router: ReturnType<typeof useRouter>;
let projectsStore: ReturnType<typeof useProjectsStore>; let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>; let usersStore: ReturnType<typeof useUsersStore>;
let settingsStore: ReturnType<typeof useSettingsStore>; let settingsStore: ReturnType<typeof useSettingsStore>;
let rolesStore: ReturnType<typeof useRolesStore>;
describe('ProjectSettings', () => { describe('ProjectSettings', () => {
beforeEach(() => { beforeEach(() => {
@@ -49,6 +51,7 @@ describe('ProjectSettings', () => {
projectsStore = useProjectsStore(); projectsStore = useProjectsStore();
usersStore = useUsersStore(); usersStore = useUsersStore();
settingsStore = useSettingsStore(); settingsStore = useSettingsStore();
rolesStore = useRolesStore();
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve()); vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {}); vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
@@ -66,12 +69,49 @@ describe('ProjectSettings', () => {
enabled: false, enabled: false,
}, },
} as FrontendSettings); } as FrontendSettings);
vi.spyOn(rolesStore, 'processedProjectRoles', 'get').mockReturnValue([
{
slug: 'project:admin',
displayName: 'Project Admin',
description: 'Can manage project settings',
licensed: true,
roleType: 'project',
scopes: ['project:read', 'project:write', 'project:delete'],
systemRole: true,
},
{
slug: 'project:editor',
displayName: 'Project Editor',
description: 'Can edit project settings',
licensed: true,
roleType: 'project',
scopes: ['project:read', 'project:write'],
systemRole: true,
},
{
slug: 'project:custom',
displayName: 'Custom',
description: 'Can do some custom actions',
licensed: true,
roleType: 'project',
scopes: ['workflow:list'],
systemRole: false,
},
]);
projectsStore.setCurrentProject({ projectsStore.setCurrentProject({
id: '123', id: '123',
type: 'team', type: 'team',
name: 'Test Project', name: 'Test Project',
icon: { type: 'icon', value: 'folder' }, icon: { type: 'icon', value: 'folder' },
relations: [], relations: [
{
id: '1',
lastName: 'Doe',
firstName: 'John',
role: 'project:admin',
email: 'admin@example.com',
},
],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
scopes: [], scopes: [],
@@ -132,4 +172,15 @@ describe('ProjectSettings', () => {
expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined); expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined);
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE }); expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
}); });
it('should show role dropdown', async () => {
const { getByTestId } = renderComponent();
const roleDropdown = getByTestId('projects-settings-user-role-select');
expect(roleDropdown).toBeVisible();
const roleDropdownItems = await getDropdownItems(roleDropdown);
expect(roleDropdownItems).toHaveLength(3);
expect(roleDropdownItems[0]).toHaveTextContent('Admin');
expect(roleDropdownItems[1]).toHaveTextContent('Editor');
expect(roleDropdownItems[2]).toHaveTextContent('Custom');
});
}); });

View File

@@ -79,7 +79,7 @@ const projects = computed(() =>
const projectRoles = computed(() => const projectRoles = computed(() =>
rolesStore.processedProjectRoles.map((role) => ({ rolesStore.processedProjectRoles.map((role) => ({
...role, ...role,
displayName: projectRoleTranslations.value[role.slug], displayName: projectRoleTranslations.value[role.slug] ?? role.displayName,
})), })),
); );
const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.slug); const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.slug);