feat(editor): Refactor the executable node logic (no-changelog) (#16848)

This commit is contained in:
Daria
2025-07-01 12:29:49 +03:00
committed by GitHub
parent c51842bd52
commit 503beea8d1
5 changed files with 398 additions and 73 deletions

View File

@@ -19,7 +19,6 @@ import TriggerPanel from './TriggerPanel.vue';
import { import {
APP_MODALS_ELEMENT_ID, APP_MODALS_ELEMENT_ID,
BASE_NODE_SURVEY_URL, BASE_NODE_SURVEY_URL,
EnterpriseEditionFeature,
EXECUTABLE_TRIGGER_NODE_TYPES, EXECUTABLE_TRIGGER_NODE_TYPES,
MODAL_CONFIRM, MODAL_CONFIRM,
START_NODE_TYPE, START_NODE_TYPE,
@@ -31,7 +30,6 @@ import { dataPinningEventBus, ndvEventBus } from '@/event-bus';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport'; import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
@@ -76,7 +74,6 @@ const pinnedData = usePinnedData(activeNode);
const workflowActivate = useWorkflowActivate(); const workflowActivate = useWorkflowActivate();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const deviceSupport = useDeviceSupport(); const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
@@ -333,25 +330,9 @@ const blockUi = computed(
() => workflowsStore.isWorkflowRunning || isExecutionWaitingForWebhook.value, () => workflowsStore.isWorkflowRunning || isExecutionWaitingForWebhook.value,
); );
const foreignCredentials = computed(() => { const foreignCredentials = computed(() =>
const credentials = activeNode.value?.credentials; nodeHelpers.getForeignCredentialsIfSharingEnabled(activeNode.value?.credentials),
const usedCredentials = workflowsStore.usedCredentials; );
const foreignCredentialsArray: string[] = [];
if (credentials && settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
Object.values(credentials).forEach((credential) => {
if (
credential.id &&
usedCredentials[credential.id] &&
!usedCredentials[credential.id].currentUserHasAccess
) {
foreignCredentialsArray.push(credential.id);
}
});
}
return foreignCredentialsArray;
});
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0); const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
@@ -850,7 +831,6 @@ onBeforeUnmount(() => {
:event-bus="settingsEventBus" :event-bus="settingsEventBus"
:dragging="isDragging" :dragging="isDragging"
:push-ref="pushRef" :push-ref="pushRef"
:node-type="activeNodeType"
:foreign-credentials="foreignCredentials" :foreign-credentials="foreignCredentials"
:read-only="readOnly" :read-only="readOnly"
:block-u-i="blockUi && showTriggerPanel" :block-u-i="blockUi && showTriggerPanel"

View File

@@ -1,14 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { import type {
INodeTypeDescription,
INodeParameters, INodeParameters,
INodeProperties, INodeProperties,
NodeConnectionType, NodeConnectionType,
NodeParameterValue, NodeParameterValue,
INodeCredentialDescription, INodeCredentialDescription,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers, NodeConnectionTypes, deepCopy } from 'n8n-workflow'; import { NodeHelpers, deepCopy } from 'n8n-workflow';
import type { import type {
CurlToJSONResponse, CurlToJSONResponse,
INodeUi, INodeUi,
@@ -49,7 +48,6 @@ const props = withDefaults(
eventBus: EventBus; eventBus: EventBus;
dragging: boolean; dragging: boolean;
pushRef: string; pushRef: string;
nodeType: INodeTypeDescription | null;
readOnly: boolean; readOnly: boolean;
foreignCredentials: string[]; foreignCredentials: string[];
blockUI: boolean; blockUI: boolean;
@@ -134,31 +132,17 @@ const isReadOnly = computed(
); );
const node = computed(() => props.activeNode ?? ndvStore.activeNode); const node = computed(() => props.activeNode ?? ndvStore.activeNode);
const nodeType = computed(() =>
node.value ? nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion) : null,
);
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type)); const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type)); const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
const isExecutable = computed(() => { const isExecutable = computed(() =>
if (props.nodeType && node.value) { nodeHelpers.isNodeExecutable(node.value, props.executable, props.foreignCredentials),
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name); );
const inputs = NodeHelpers.getNodeInputs(
currentWorkflowInstance.value,
workflowNode!,
props.nodeType,
);
const inputNames = NodeHelpers.getConnectionTypes(inputs);
if (
!inputNames.includes(NodeConnectionTypes.Main) &&
!isToolNode.value &&
!isTriggerNode.value
) {
return false;
}
}
return props.executable || props.foreignCredentials.length > 0;
});
const nodeTypeVersions = computed(() => { const nodeTypeVersions = computed(() => {
if (!node.value) return []; if (!node.value) return [];
@@ -186,7 +170,7 @@ const executeButtonTooltip = computed(() => {
}); });
const nodeVersionTag = computed(() => { const nodeVersionTag = computed(() => {
if (!props.nodeType || props.nodeType.hidden) { if (!nodeType.value || nodeType.value.hidden) {
return i18n.baseText('nodeSettings.deprecated'); return i18n.baseText('nodeSettings.deprecated');
} }
@@ -200,11 +184,11 @@ const nodeVersionTag = computed(() => {
}); });
const parameters = computed(() => { const parameters = computed(() => {
if (props.nodeType === null) { if (nodeType.value === null) {
return []; return [];
} }
return props.nodeType?.properties ?? []; return nodeType.value?.properties ?? [];
}); });
const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting)); const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting));
@@ -219,7 +203,7 @@ const parametersNoneSetting = computed(() => {
const isDisplayingCredentials = computed( const isDisplayingCredentials = computed(
() => () =>
credentialsStore credentialsStore
.getCredentialTypesNodeDescriptions('', props.nodeType) .getCredentialTypesNodeDescriptions('', nodeType.value)
.filter((credentialTypeDescription) => displayCredentials(credentialTypeDescription)).length > .filter((credentialTypeDescription) => displayCredentials(credentialTypeDescription)).length >
0, 0,
); );
@@ -292,19 +276,19 @@ const valueChanged = (parameterData: IUpdateInformation) => {
}; };
emit('valueChanged', sendData); emit('valueChanged', sendData);
} else if (parameterData.name === 'parameters') { } else if (parameterData.name === 'parameters') {
const nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion); const _nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion);
if (!nodeType) { if (!_nodeType) {
return; return;
} }
// Get only the parameters which are different to the defaults // Get only the parameters which are different to the defaults
let nodeParameters = NodeHelpers.getNodeParameters( let nodeParameters = NodeHelpers.getNodeParameters(
nodeType.properties, _nodeType.properties,
_node.parameters, _node.parameters,
false, false,
false, false,
_node, _node,
nodeType, _nodeType,
); );
const oldNodeParameters = Object.assign({}, nodeParameters); const oldNodeParameters = Object.assign({}, nodeParameters);
@@ -321,7 +305,7 @@ const valueChanged = (parameterData: IUpdateInformation) => {
parameterName, parameterName,
newValue, newValue,
nodeParameters, nodeParameters,
nodeType, _nodeType,
_node.typeVersion, _node.typeVersion,
); );
@@ -337,12 +321,12 @@ const valueChanged = (parameterData: IUpdateInformation) => {
// Get the parameters with the now new defaults according to the // Get the parameters with the now new defaults according to the
// from the user actually defined parameters // from the user actually defined parameters
nodeParameters = NodeHelpers.getNodeParameters( nodeParameters = NodeHelpers.getNodeParameters(
nodeType.properties, _nodeType.properties,
nodeParameters as INodeParameters, nodeParameters as INodeParameters,
true, true,
false, false,
_node, _node,
nodeType, _nodeType,
); );
for (const key of Object.keys(nodeParameters as object)) { for (const key of Object.keys(nodeParameters as object)) {
@@ -581,7 +565,7 @@ const setNodeValues = () => {
return; return;
} }
if (props.nodeType !== null) { if (nodeType.value !== null) {
nodeValid.value = true; nodeValid.value = true;
const foundNodeSettings = []; const foundNodeSettings = [];
@@ -725,7 +709,7 @@ onMounted(() => {
setNodeValues(); setNodeValues();
props.eventBus?.on('openSettings', openSettings); props.eventBus?.on('openSettings', openSettings);
if (node.value !== null) { if (node.value !== null) {
nodeHelpers.updateNodeParameterIssues(node.value, props.nodeType); nodeHelpers.updateNodeParameterIssues(node.value, nodeType.value);
} }
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters); importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
ndvEventBus.on('updateParameterValue', valueChanged); ndvEventBus.on('updateParameterValue', valueChanged);

View File

@@ -2,7 +2,6 @@
import NodeSettings from '@/components/NodeSettings.vue'; import NodeSettings from '@/components/NodeSettings.vue';
import { useCanvasOperations } from '@/composables/useCanvasOperations'; import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { type IUpdateInformation } from '@/Interface'; import { type IUpdateInformation } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { computed } from 'vue'; import { computed } from 'vue';
@@ -12,17 +11,10 @@ const { nodeId, noWheel } = defineProps<{ nodeId: string; noWheel?: boolean }>()
defineSlots<{ actions?: {} }>(); defineSlots<{ actions?: {} }>();
const settingsEventBus = createEventBus(); const settingsEventBus = createEventBus();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const { renameNode } = useCanvasOperations(); const { renameNode } = useCanvasOperations();
const activeNode = computed(() => workflowsStore.getNodeById(nodeId)); const activeNode = computed(() => workflowsStore.getNodeById(nodeId));
const activeNodeType = computed(() => {
if (activeNode.value) {
return nodeTypesStore.getNodeType(activeNode.value.type, activeNode.value.typeVersion);
}
return null;
});
function handleValueChanged(parameterData: IUpdateInformation) { function handleValueChanged(parameterData: IUpdateInformation) {
if (parameterData.name === 'name' && parameterData.oldValue) { if (parameterData.name === 'name' && parameterData.oldValue) {
@@ -36,7 +28,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
:event-bus="settingsEventBus" :event-bus="settingsEventBus"
:dragging="false" :dragging="false"
:active-node="activeNode" :active-node="activeNode"
:node-type="activeNodeType"
push-ref="" push-ref=""
:foreign-credentials="[]" :foreign-credentials="[]"
:read-only="false" :read-only="false"

View File

@@ -1,13 +1,23 @@
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import type { INode, INodeTypeDescription, Workflow } from 'n8n-workflow'; import {
NodeConnectionTypes,
NodeHelpers,
type INode,
type INodeTypeDescription,
type Workflow,
} from 'n8n-workflow';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { createTestNode } from '@/__tests__/mocks'; import { createTestNode, createMockEnterpriseSettings } from '@/__tests__/mocks';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { CUSTOM_API_CALL_KEY } from '@/constants'; import { useSettingsStore } from '@/stores/settings.store';
import { CUSTOM_API_CALL_KEY, EnterpriseEditionFeature } from '@/constants';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { ExecutionStatus, IRunData } from 'n8n-workflow'; import type { ExecutionStatus, IRunData } from 'n8n-workflow';
import { faker } from '@faker-js/faker';
import type { INodeUi, IUsedCredential } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
describe('useNodeHelpers()', () => { describe('useNodeHelpers()', () => {
beforeAll(() => { beforeAll(() => {
@@ -18,6 +28,293 @@ describe('useNodeHelpers()', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('isNodeExecutable()', () => {
it('should return true if the node is null but explicitly executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const result = isNodeExecutable(null, true, []);
expect(result).toBe(true);
});
it('should return false if node has no Main input and is not trigger or tool', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
id: 'workflow-id',
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.AiDocument]);
const result = isNodeExecutable(node, true, []);
expect(result).toBe(false);
});
it('should return true if node has Main input and is marked executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, true, []);
expect(result).toBe(true);
});
it('should return true if node has foreign credentials even if not marked executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, false, ['foreign-cred-id']);
expect(result).toBe(true);
});
it('should return true for trigger nodes regardless of inputs', () => {
const { isNodeExecutable } = useNodeHelpers();
const triggerNode: INodeUi = {
id: 'node-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => triggerNode,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(true);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
const result = isNodeExecutable(triggerNode, true, []);
expect(result).toBe(true);
});
it('should return true for tool nodes regardless of inputs', () => {
const { isNodeExecutable } = useNodeHelpers();
const toolNode: INodeUi = {
id: 'node-id',
name: 'Tool Node',
type: 'n8n-nodes-base.ai-tool',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => toolNode,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(true);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([]);
const result = isNodeExecutable(toolNode, true, []);
expect(result).toBe(true);
});
it('should return true if node is structurally valid and has foreign credentials, even if not executable', () => {
const { isNodeExecutable } = useNodeHelpers();
const node: INodeUi = {
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const mockWorkflow = {
getNode: () => node,
};
mockedStore(useWorkflowsStore).getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue({});
mockedStore(useNodeTypesStore).isTriggerNode = vi.fn().mockReturnValue(false);
mockedStore(useNodeTypesStore).isToolNode = vi.fn().mockReturnValue(false);
vi.spyOn(NodeHelpers, 'getNodeInputs').mockReturnValue([]);
vi.spyOn(NodeHelpers, 'getConnectionTypes').mockReturnValue([NodeConnectionTypes.Main]);
const result = isNodeExecutable(node, false, ['cred-1']);
expect(result).toBe(true);
});
});
describe('getForeignCredentialsIfSharingEnabled()', () => {
it('should return an empty array when user has the wrong license', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithoutAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: false,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: false,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithoutAccess.id]: credentialWithoutAccess,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithoutAccess.id]: {
id: credentialWithoutAccess.id,
name: credentialWithoutAccess.name,
},
});
expect(result).toEqual([]);
});
it('should return an empty array when credentials are undefined', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
const result = getForeignCredentialsIfSharingEnabled(undefined);
expect(result).toEqual([]);
});
it('should return an empty array when user has access to all credentials', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithAccess1: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
const credentialWithAccess2: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithAccess1.id]: credentialWithAccess1,
[credentialWithAccess2.id]: credentialWithAccess2,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithAccess1.id]: {
id: credentialWithAccess1.id,
name: credentialWithAccess1.name,
},
[credentialWithAccess2.id]: {
id: credentialWithAccess2.id,
name: credentialWithAccess2.name,
},
});
expect(result).toEqual([]);
});
it('should return an array of foreign credentials', () => {
const { getForeignCredentialsIfSharingEnabled } = useNodeHelpers();
const credentialWithAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: true,
};
const credentialWithoutAccess: IUsedCredential = {
id: faker.string.alphanumeric(10),
credentialType: 'generic',
name: faker.lorem.words(2),
currentUserHasAccess: false,
};
mockedStore(useSettingsStore).isEnterpriseFeatureEnabled = createMockEnterpriseSettings({
[EnterpriseEditionFeature.Sharing]: true,
});
mockedStore(useWorkflowsStore).usedCredentials = {
[credentialWithAccess.id]: credentialWithAccess,
[credentialWithoutAccess.id]: credentialWithoutAccess,
};
const result = getForeignCredentialsIfSharingEnabled({
[credentialWithAccess.id]: {
id: credentialWithAccess.id,
name: credentialWithAccess.name,
},
[credentialWithoutAccess.id]: {
id: credentialWithoutAccess.id,
name: credentialWithoutAccess.name,
},
});
expect(result).toEqual([credentialWithoutAccess.id]);
});
});
describe('isCustomApiCallSelected', () => { describe('isCustomApiCallSelected', () => {
test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => { test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => {
const nodeValues = { const nodeValues = {

View File

@@ -1,6 +1,10 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useHistoryStore } from '@/stores/history.store'; import { useHistoryStore } from '@/stores/history.store';
import { CUSTOM_API_CALL_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME } from '@/constants'; import {
CUSTOM_API_CALL_KEY,
EnterpriseEditionFeature,
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
} from '@/constants';
import { NodeHelpers, NodeConnectionTypes } from 'n8n-workflow'; import { NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
import type { import type {
@@ -26,6 +30,7 @@ import type {
NodeConnectionType, NodeConnectionType,
IRunExecutionData, IRunExecutionData,
NodeHint, NodeHint,
INodeCredentials,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { import type {
@@ -47,6 +52,7 @@ import { EnableNodeToggleCommand } from '@/models/history';
import { useTelemetry } from './useTelemetry'; import { useTelemetry } from './useTelemetry';
import { hasPermission } from '@/utils/rbac/permissions'; import { hasPermission } from '@/utils/rbac/permissions';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
declare namespace HttpRequestNode { declare namespace HttpRequestNode {
namespace V2 { namespace V2 {
@@ -63,6 +69,7 @@ export function useNodeHelpers() {
const historyStore = useHistoryStore(); const historyStore = useHistoryStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const i18n = useI18n(); const i18n = useI18n();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
@@ -92,6 +99,70 @@ export function useNodeHelpers() {
return false; return false;
} }
/**
* Determines whether a given node is considered executable in the workflow editor.
*
* A node is considered executable if:
* - It structurally qualifies for execution (e.g. is a trigger, tool, or has a 'Main' input),
* AND
* - It is either explicitly marked as `executable`, OR uses foreign credentials
* (credentials the current user cannot access, allowed under Workflow Sharing).
*
* @param node The node to check
* @param executable Whether the node is in a state that allows execution (e.g. not readonly)
* @param foreignCredentials List of credential IDs that the current user cannot access
*/
function isNodeExecutable(
node: INodeUi | null,
executable: boolean | undefined,
foreignCredentials: string[],
): boolean {
const nodeType = node ? nodeTypesStore.getNodeType(node.type, node.typeVersion) : null;
if (node && nodeType) {
const currentWorkflowInstance = workflowsStore.getCurrentWorkflow();
const workflowNode = currentWorkflowInstance.getNode(node.name);
const isTriggerNode = !!node && nodeTypesStore.isTriggerNode(node.type);
const isToolNode = !!node && nodeTypesStore.isToolNode(node.type);
if (workflowNode) {
const inputs = NodeHelpers.getNodeInputs(currentWorkflowInstance, workflowNode, nodeType);
const inputNames = NodeHelpers.getConnectionTypes(inputs);
if (!inputNames.includes(NodeConnectionTypes.Main) && !isToolNode && !isTriggerNode) {
return false;
}
}
}
return Boolean(executable || foreignCredentials.length > 0);
}
/**
* Returns a list of credential IDs that the current user does not have access to,
* if the Sharing feature is enabled.
*
* These are considered "foreign" credentials: the user can't view or manage them,
* but can still execute workflows that use them.
*/
function getForeignCredentialsIfSharingEnabled(
credentials: INodeCredentials | undefined,
): string[] {
if (
!credentials ||
!settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]
) {
return [];
}
const usedCredentials = workflowsStore.usedCredentials;
return Object.values(credentials)
.map(({ id }) => id)
.filter((id) => id !== null)
.filter((id) => id in usedCredentials && !usedCredentials[id]?.currentUserHasAccess);
}
function getParameterValue(nodeValues: INodeParameters, parameterName: string, path: string) { function getParameterValue(nodeValues: INodeParameters, parameterName: string, path: string) {
return get(nodeValues, path ? path + '.' + parameterName : parameterName); return get(nodeValues, path ? path + '.' + parameterName : parameterName);
} }
@@ -1005,6 +1076,8 @@ export function useNodeHelpers() {
return { return {
hasProxyAuth, hasProxyAuth,
isCustomApiCallSelected, isCustomApiCallSelected,
isNodeExecutable,
getForeignCredentialsIfSharingEnabled,
getParameterValue, getParameterValue,
displayParameter, displayParameter,
getNodeIssues, getNodeIssues,