From bf198f82632564079da64c089a4530c051e8ad9d Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 1 Sep 2025 11:18:22 +0200 Subject: [PATCH] feat(editor): Display custom roles in the project role dropdown (#18983) Co-authored-by: Andreas Fitzek --- .../editor-ui/src/stores/roles.store.ts | 8 +-- .../src/views/ProjectSettings.test.ts | 53 ++++++++++++++++++- .../editor-ui/src/views/ProjectSettings.vue | 2 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/frontend/editor-ui/src/stores/roles.store.ts b/packages/frontend/editor-ui/src/stores/roles.store.ts index bc26501f8e..b2920813d7 100644 --- a/packages/frontend/editor-ui/src/stores/roles.store.ts +++ b/packages/frontend/editor-ui/src/stores/roles.store.ts @@ -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 { ref, computed } from 'vue'; import * as rolesApi from '@n8n/rest-api-client/api/roles'; @@ -20,11 +20,11 @@ export const useRolesStore = defineStore('roles', () => { const processedProjectRoles = computed(() => roles.value.project - .filter((role) => projectRoleOrderMap.value.has(role.slug)) + .filter((role) => role.slug !== PROJECT_OWNER_ROLE_SLUG) .sort( (a, b) => - (projectRoleOrderMap.value.get(a.slug) ?? 0) - - (projectRoleOrderMap.value.get(b.slug) ?? 0), + (projectRoleOrderMap.value.get(a.slug) ?? Number.MAX_SAFE_INTEGER) - + (projectRoleOrderMap.value.get(b.slug) ?? Number.MAX_SAFE_INTEGER), ), ); diff --git a/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts b/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts index 60c1d0f3e1..ba0bd75dc3 100644 --- a/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts +++ b/packages/frontend/editor-ui/src/views/ProjectSettings.test.ts @@ -12,6 +12,7 @@ import { createProjectListItem } from '@/__tests__/data/projects'; import { useSettingsStore } from '@/stores/settings.store'; import type { FrontendSettings } from '@n8n/api-types'; import { ProjectTypes } from '@/types/projects.types'; +import { useRolesStore } from '@/stores/roles.store'; vi.mock('vue-router', () => { const params = {}; @@ -40,6 +41,7 @@ let router: ReturnType; let projectsStore: ReturnType; let usersStore: ReturnType; let settingsStore: ReturnType; +let rolesStore: ReturnType; describe('ProjectSettings', () => { beforeEach(() => { @@ -49,6 +51,7 @@ describe('ProjectSettings', () => { projectsStore = useProjectsStore(); usersStore = useUsersStore(); settingsStore = useSettingsStore(); + rolesStore = useRolesStore(); vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve()); vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {}); @@ -66,12 +69,49 @@ describe('ProjectSettings', () => { enabled: false, }, } 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({ id: '123', type: 'team', name: 'Test Project', icon: { type: 'icon', value: 'folder' }, - relations: [], + relations: [ + { + id: '1', + lastName: 'Doe', + firstName: 'John', + role: 'project:admin', + email: 'admin@example.com', + }, + ], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), scopes: [], @@ -132,4 +172,15 @@ describe('ProjectSettings', () => { expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined); 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'); + }); }); diff --git a/packages/frontend/editor-ui/src/views/ProjectSettings.vue b/packages/frontend/editor-ui/src/views/ProjectSettings.vue index d18cdc9f84..7ef85c9895 100644 --- a/packages/frontend/editor-ui/src/views/ProjectSettings.vue +++ b/packages/frontend/editor-ui/src/views/ProjectSettings.vue @@ -79,7 +79,7 @@ const projects = computed(() => const projectRoles = computed(() => rolesStore.processedProjectRoles.map((role) => ({ ...role, - displayName: projectRoleTranslations.value[role.slug], + displayName: projectRoleTranslations.value[role.slug] ?? role.displayName, })), ); const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.slug);