From 2f648098fd1687c8d4ac00341ff54bb1a92deeb9 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 3 Jun 2025 14:18:04 +0200 Subject: [PATCH] feat(editor): Enable source environment push button for project admins (#15527) --- .../src/roles/scopes/project-scopes.ee.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../MainSidebarSourceControl.test.ts | 93 +++++++++++++++++++ .../components/MainSidebarSourceControl.vue | 52 +++++++++-- 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index dd46212727a..d843d2dec8b 100644 --- a/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -31,6 +31,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'folder:delete', 'folder:list', 'folder:move', + 'sourceControl:push', ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e00a7646cdd..3baf2a4a83a 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2146,6 +2146,8 @@ "settings.sourceControl.sync.prompt.error": "Please enter a commit message", "settings.sourceControl.button.push": "Push", "settings.sourceControl.button.pull": "Pull", + "settings.sourceControl.button.pull.forbidden": "Only the instance owner or instance admins can pull changes", + "settings.sourceControl.button.push.forbidden": "You can't push changes from a protected instance", "settings.sourceControl.modals.push.title": "Commit and push changes", "settings.sourceControl.modals.push.description": "The following will be committed: ", "settings.sourceControl.modals.push.description.learnMore": "More info", diff --git a/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.test.ts b/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.test.ts index d4d69e77d68..af92f39f176 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.test.ts +++ b/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.test.ts @@ -11,11 +11,13 @@ import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores/ui.store'; import { useRBACStore } from '@/stores/rbac.store'; import { createComponentRenderer } from '@/__tests__/render'; +import { useProjectsStore } from '@/stores/projects.store'; let pinia: ReturnType; let sourceControlStore: ReturnType; let uiStore: ReturnType; let rbacStore: ReturnType; +let projectStore: ReturnType; const showMessage = vi.fn(); const showError = vi.fn(); @@ -38,6 +40,7 @@ describe('MainSidebarSourceControl', () => { }); rbacStore = useRBACStore(pinia); + projectStore = useProjectsStore(pinia); vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true); sourceControlStore = useSourceControlStore(); @@ -58,8 +61,72 @@ describe('MainSidebarSourceControl', () => { expect(getByTestId('main-sidebar-source-control')).toBeEmptyDOMElement(); }); + describe('when connected as project admin', () => { + beforeEach(() => { + vi.spyOn(rbacStore, 'hasScope').mockReturnValue(false); + vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({ + branchName: 'main', + branches: [], + repositoryUrl: '', + branchReadOnly: false, + branchColor: '#5296D6', + connected: true, + publicKey: '', + }); + projectStore.myProjects = [ + { + id: '1', + name: 'Test Project', + type: 'team', + scopes: ['sourceControl:push'], + icon: { type: 'emoji', value: '🚀' }, + createdAt: '2023-01-01T00:00:00Z', + updatedAt: '2023-01-01T00:00:00Z', + role: 'project:admin', + }, + ]; + }); + + it('should render the appropriate content', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + pinia, + props: { isCollapsed: false }, + }); + expect(getByTestId('main-sidebar-source-control-connected')).toBeInTheDocument(); + expect(queryByTestId('main-sidebar-source-control-setup')).not.toBeInTheDocument(); + + const pushButton = queryByTestId('main-sidebar-source-control-push'); + expect(pushButton).toBeInTheDocument(); + expect(pushButton).not.toBeDisabled(); + + const pullButton = queryByTestId('main-sidebar-source-control-pull'); + expect(pullButton).toBeInTheDocument(); + expect(pullButton).toBeDisabled(); + }); + + it('should disable push button if branch is read-only', async () => { + vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({ + branchName: 'main', + branches: [], + repositoryUrl: '', + branchReadOnly: true, + branchColor: '#5296D6', + connected: true, + publicKey: '', + }); + + const { getByTestId } = renderComponent({ + pinia, + props: { isCollapsed: false }, + }); + const pushButton = getByTestId('main-sidebar-source-control-push'); + expect(pushButton).toBeDisabled(); + }); + }); + describe('when connected', () => { beforeEach(() => { + vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true); vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({ branchName: 'main', branches: [], @@ -78,6 +145,32 @@ describe('MainSidebarSourceControl', () => { }); expect(getByTestId('main-sidebar-source-control-connected')).toBeInTheDocument(); expect(queryByTestId('main-sidebar-source-control-setup')).not.toBeInTheDocument(); + + const pushButton = queryByTestId('main-sidebar-source-control-push'); + expect(pushButton).toBeInTheDocument(); + expect(pushButton).not.toBeDisabled(); + + const pullButton = queryByTestId('main-sidebar-source-control-pull'); + expect(pullButton).toBeInTheDocument(); + expect(pullButton).not.toBeDisabled(); + }); + + it('should disable push button if branch is read-only', async () => { + vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({ + branchName: 'main', + branches: [], + repositoryUrl: '', + branchReadOnly: true, + branchColor: '#5296D6', + connected: true, + publicKey: '', + }); + const { getByTestId } = renderComponent({ + pinia, + props: { isCollapsed: false }, + }); + const pushButton = getByTestId('main-sidebar-source-control-push'); + expect(pushButton).toBeDisabled(); }); it('should show toast error if pull response http status code is not 409', async () => { diff --git a/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.vue b/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.vue index 8a4c24c5605..19f8fa4d4e6 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.vue +++ b/packages/frontend/editor-ui/src/components/MainSidebarSourceControl.vue @@ -3,6 +3,7 @@ import { computed, ref } from 'vue'; import { createEventBus } from '@n8n/utils/event-bus'; import { useI18n } from '@n8n/i18n'; import { hasPermission } from '@/utils/rbac/permissions'; +import { getResourcePermissions } from '@/permissions'; import { useToast } from '@/composables/useToast'; import { useLoadingService } from '@/composables/useLoadingService'; import { useUIStore } from '@/stores/ui.store'; @@ -10,6 +11,7 @@ import { useSourceControlStore } from '@/stores/sourceControl.store'; import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants'; import { sourceControlEventBus } from '@/event-bus/source-control'; import { notifyUserAboutPullWorkFolderOutcome } from '@/utils/sourceControlUtils'; +import { useProjectsStore } from '@/stores/projects.store'; defineProps<{ isCollapsed: boolean; @@ -22,6 +24,7 @@ const responseStatuses = { const loadingService = useLoadingService(); const uiStore = useUIStore(); const sourceControlStore = useSourceControlStore(); +const projectStore = useProjectsStore(); const toast = useToast(); const i18n = useI18n(); @@ -31,10 +34,26 @@ const tooltipOpenDelay = ref(300); const currentBranch = computed(() => { return sourceControlStore.preferences.branchName; }); + +// Check if the user has permission to push for at least one project +const hasPushPermission = computed(() => { + return ( + hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } }) || + projectStore.myProjects.some( + (project) => + project.type === 'team' && getResourcePermissions(project?.scopes)?.sourceControl?.push, + ) + ); +}); + +const hasPullPermission = computed(() => { + return hasPermission(['rbac'], { rbac: { scope: 'sourceControl:pull' } }); +}); + const sourceControlAvailable = computed( () => sourceControlStore.isEnterpriseSourceControlEnabled && - hasPermission(['rbac'], { rbac: { scope: 'sourceControl:manage' } }), + (hasPullPermission.value || hasPushPermission.value), ); async function pushWorkfolder() { @@ -113,17 +132,27 @@ async function pullWorkfolder() { {{ currentBranch }}
- +