From deaa9b105477ef0ace3ddedaa32f37c77a106ca3 Mon Sep 17 00:00:00 2001 From: Yuliia Pominchuk <31064937+yuliia-pominchuk@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:06:04 +0000 Subject: [PATCH] feat(editor): Stop Execute and Publish when a credential is not yours to use (#39883) Co-authored-by: MHMD Co-authored-by: Claude Opus 5 (1M context) --- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../WorkflowHeaderDraftPublishActions.test.ts | 141 ++++++++++++++++++ .../WorkflowHeaderDraftPublishActions.vue | 52 +++++-- .../app/composables/useNodeExecution.test.ts | 126 +++++++++++++++- .../src/app/composables/useNodeExecution.ts | 10 ++ .../src/app/composables/useNodeHelpers.ts | 4 + .../editor-ui/src/app/views/NodeView.test.ts | 99 +++++++++++- .../editor-ui/src/app/views/NodeView.vue | 29 +++- .../useUnusableWorkflowCredentials.test.ts | 134 +++++++++++++++++ .../useUnusableWorkflowCredentials.ts | 91 +++++++++++ .../buttons/CanvasRunWorkflowButton.test.ts | 29 ++++ .../buttons/CanvasRunWorkflowButton.vue | 8 +- 12 files changed, 708 insertions(+), 17 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/credentials/composables/useUnusableWorkflowCredentials.test.ts create mode 100644 packages/frontend/editor-ui/src/features/credentials/composables/useUnusableWorkflowCredentials.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5daf14365b5..cc299acb8fa 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1176,6 +1176,8 @@ "credentialEdit.credentialEdit.docs": "Docs", "credentialEdit.oAuthButton.connectMyAccount": "Connect my account", "credentialEdit.oAuthButton.signInWithGoogle": "Sign in with Google", + "credentialSharing.blocked": "Uses {credential}. Only {owner} can run or publish with it. You can still edit the workflow and switch to a credential you can use.", + "credentialSharing.blocked.unknownOwner": "Uses {credential}. Only its owner can run or publish with it. You can still edit the workflow and switch to a credential you can use.", "credentialEdit.credentialSharing.info.owner": "Sharing a credential allows people to use it in their workflows. They cannot access credential details.", "credentialEdit.credentialSharing.info.sharee.team": "Only users with credential sharing permission can change who this credential is shared with", "credentialEdit.credentialSharing.info.sharee.personal": "Only {credentialOwnerName} or users with credential sharing permission can change who this credential is shared with", diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts index 54861930f54..913f118d790 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts @@ -17,6 +17,7 @@ import { WORKFLOW_PUBLISH_MODAL_KEY, EnterpriseEditionFeature } from '@/app/cons import { MANUAL_TRIGGER_NODE_TYPE } from '@/app/constants/nodeTypes'; import { STORES } from '@n8n/stores'; import type { INodeUi } from '@/Interface'; +import type { IUsedCredential } from '@/features/credentials/credentials.types'; import { ProjectTypes } from '@/features/collaboration/projects/projects.types'; import { createTestProject } from '@/features/collaboration/projects/__tests__/utils'; import { @@ -24,6 +25,7 @@ import { createWorkflowDocumentId, } from '@/app/stores/workflowDocument.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import { nodeViewEventBus } from '@/app/event-bus'; import { useReviewRequiredStore } from '@/features/workflow-reviews/reviewRequired.store'; import { useWorkflowReviewStatusStore } from '@/features/workflow-reviews/reviewStatus.store'; import { @@ -1061,6 +1063,145 @@ describe('WorkflowHeaderDraftPublishActions', () => { }); }); + describe('Unusable credential', () => { + const foreignCredential: IUsedCredential = { + id: 'c1', + name: "Alice's Gmail", + credentialType: 'gmailOAuth2', + currentUserCanUse: false, + homeProject: { + id: 'p1', + name: 'Alice Chen ', + type: 'personal', + icon: null, + createdAt: '', + updatedAt: '', + }, + }; + + const useCredential = (credential: IUsedCredential, { flagEnabled = true } = {}) => { + settingsStore.settings = { + ...settingsStore.settings, + granularCredentialSharing: flagEnabled, + }; + workflowDocumentStore.setUsedCredentials([credential]); + workflowDocumentStore.setNodes([ + { + ...triggerNode, + credentials: { gmailOAuth2: { id: credential.id, name: credential.name } }, + }, + ]); + }; + + // The file-wide stub always renders the tooltip content. This one honours + // `disabled`, so a suppressed tooltip is visible as a missing reason. + const tooltipStubs = { + N8nTooltip: { + props: { disabled: Boolean }, + template: '
', + }, + }; + + it('should disable publish and name the credential and its owner', () => { + useCredential(foreignCredential); + + const { getByTestId, getByText } = renderComponent(); + + expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled(); + expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument(); + }); + + it('should not open the publish modal', async () => { + const openModalSpy = vi.spyOn(uiStore, 'openModalWithData'); + useCredential(foreignCredential); + + const { getByTestId } = renderComponent(); + + await userEvent.click(getByTestId('workflow-open-publish-modal-button')); + + expect(openModalSpy).not.toHaveBeenCalled(); + expect(mockSaveCurrentWorkflow).not.toHaveBeenCalled(); + }); + + // No permission makes the workflow publishable, so this reason is the useful one. + it('should give the credential reason before the permission one', () => { + useCredential(foreignCredential); + + const { getByText, queryByText } = renderComponent({ + props: { + ...defaultWorkflowProps, + workflowPermissions: { ...defaultWorkflowProps.workflowPermissions, publish: false }, + }, + }); + + expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument(); + expect( + queryByText("You don't have permission to publish this workflow"), + ).not.toBeInTheDocument(); + }); + + // A draft that is otherwise ready to publish hides the tooltip, and that is + // the state a colleague opening someone else's workflow lands in. + it('should still explain itself on a draft that is otherwise publishable', () => { + useCredential(foreignCredential); + workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null }); + + const { getByTestId, getByText } = renderComponent({ + global: { stubs: tooltipStubs }, + }); + + expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled(); + expect(getByText(/Only Alice Chen can run or publish with it/)).toBeInTheDocument(); + }); + + // The NDV and the command bar publish through the event bus, which never + // sees the button's disabled state. + it('should stop a publish command from the event bus', async () => { + const openModalSpy = vi.spyOn(uiStore, 'openModalWithData'); + useCredential(foreignCredential); + + renderComponent(); + nodeViewEventBus.emit('publishWorkflow'); + await waitFor(() => expect(mockShowMessage).toHaveBeenCalled()); + + expect(openModalSpy).not.toHaveBeenCalled(); + expect(mockSaveCurrentWorkflow).not.toHaveBeenCalled(); + expect(mockShowMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.stringContaining('Only Alice Chen can run or publish with it'), + type: 'warning', + }), + ); + }); + + it('should let a publish command through when the user can use the credential', async () => { + const openModalSpy = vi.spyOn(uiStore, 'openModalWithData'); + useCredential({ ...foreignCredential, currentUserCanUse: true }); + + renderComponent(); + nodeViewEventBus.emit('publishWorkflow'); + + await waitFor(() => expect(openModalSpy).toHaveBeenCalled()); + expect(mockShowMessage).not.toHaveBeenCalled(); + }); + + it('should keep publish enabled when the user can use the credential', () => { + useCredential({ ...foreignCredential, currentUserCanUse: true }); + + const { getByTestId } = renderComponent(); + + expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled(); + }); + + it('should keep publish enabled while the feature flag is off', () => { + useCredential(foreignCredential, { flagEnabled: false }); + + const { getByTestId } = renderComponent(); + + expect(getByTestId('workflow-open-publish-modal-button')).not.toBeDisabled(); + }); + }); + describe('Name Version action', () => { let workflowHistoryStore: MockedStore; let settingsStore: MockedStore; diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue index 386c5a24768..50b52ce37d0 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue @@ -47,6 +47,7 @@ import { createWorkflowDocumentId, } from '@/app/stores/workflowDocument.store'; import { useWorkflowPublicationStatusSync } from '@/app/composables/useWorkflowPublicationStatusSync'; +import { useUnusableWorkflowCredentials } from '@/features/credentials/composables/useUnusableWorkflowCredentials'; import { useWorkflowReviewsFeature } from '@/features/workflow-reviews/composables/useWorkflowReviewsFeature'; import WorkflowReviewRequiredToggle from '@/features/workflow-reviews/components/WorkflowReviewRequiredToggle.vue'; import WorkflowPublishChoiceDialog from '@/features/workflow-reviews/components/WorkflowPublishChoiceDialog.vue'; @@ -78,6 +79,11 @@ const workflowDocumentStore = computed(() => // Pass a getter so the composable re-syncs internally when the user navigates // to a different workflow without this component being remounted. useWorkflowPublicationStatusSync(() => workflowDocumentStore.value.documentId); + +const { reason: unusableCredentialReason } = useUnusableWorkflowCredentials( + () => workflowDocumentStore.value.usedCredentials, + () => workflowDocumentStore.value.allNodes, +); const { refetch: refetchReviewStatus } = useWorkflowReviewStatusSync(() => props.isNewWorkflow ? undefined : props.id, ); @@ -369,6 +375,12 @@ const onOpenReviewFromBanner = async () => { }; const onPublishButtonClick = async () => { + // Event-bus callers skip the disabled button, so show the reason as a toast. + if (unusableCredentialReason.value) { + toast.showMessage({ title: unusableCredentialReason.value, type: 'warning' }); + return; + } + if (!(await ensureWorkflowSaved())) return; if (isWorkflowReviewsEnabled.value) { @@ -398,6 +410,19 @@ const onPublishButtonClick = async () => { }; const publishButtonConfig = computed(() => { + // Published workflows run as the publisher, so check credentials before permissions. + if (unusableCredentialReason.value) { + return { + text: i18n.baseText('workflows.publish'), + enabled: false, + loading: false, + showIndicator: !!activeVersion.value, + indicatorClass: activeVersion.value ? 'published' : '', + tooltip: unusableCredentialReason.value, + showVersionInfo: !!activeVersion.value, + }; + } + // Handle permission-denied state first if (!hasPublishPermission.value) { const defaultConfigForNoPermission = { @@ -558,6 +583,22 @@ const shouldDisablePublishButton = computed(() => { ); }); +/** + * A workflow that is ready to publish has nothing to explain, so the tooltip + * stays off. A credential this user cannot use is the exception: the button is + * disabled in that state too, and the reason is the only way to learn why. + */ +const isPublishTooltipDisabled = computed(() => { + if (unusableCredentialReason.value) return false; + + return ( + (workflowPublishState.value === 'not-published-eligible' && + props.workflowPermissions.publish) || + (!publishButtonConfig.value.tooltip && + !(publishButtonConfig.value.showVersionInfo && activeVersion.value)) + ); +}); + const activeVersion = computed(() => workflowDocumentStore.value.activeVersion ?? null); /** @@ -810,16 +851,7 @@ onBeforeUnmount(() => {
- +