mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Refactor the executable node logic (no-changelog) (#16848)
This commit is contained in:
@@ -19,7 +19,6 @@ import TriggerPanel from './TriggerPanel.vue';
|
||||
import {
|
||||
APP_MODALS_ELEMENT_ID,
|
||||
BASE_NODE_SURVEY_URL,
|
||||
EnterpriseEditionFeature,
|
||||
EXECUTABLE_TRIGGER_NODE_TYPES,
|
||||
MODAL_CONFIRM,
|
||||
START_NODE_TYPE,
|
||||
@@ -31,7 +30,6 @@ import { dataPinningEventBus, ndvEventBus } from '@/event-bus';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
@@ -76,7 +74,6 @@ const pinnedData = usePinnedData(activeNode);
|
||||
const workflowActivate = useWorkflowActivate();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const deviceSupport = useDeviceSupport();
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
@@ -333,25 +330,9 @@ const blockUi = computed(
|
||||
() => workflowsStore.isWorkflowRunning || isExecutionWaitingForWebhook.value,
|
||||
);
|
||||
|
||||
const foreignCredentials = computed(() => {
|
||||
const credentials = 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 foreignCredentials = computed(() =>
|
||||
nodeHelpers.getForeignCredentialsIfSharingEnabled(activeNode.value?.credentials),
|
||||
);
|
||||
|
||||
const hasForeignCredential = computed(() => foreignCredentials.value.length > 0);
|
||||
|
||||
@@ -850,7 +831,6 @@ onBeforeUnmount(() => {
|
||||
:event-bus="settingsEventBus"
|
||||
:dragging="isDragging"
|
||||
:push-ref="pushRef"
|
||||
:node-type="activeNodeType"
|
||||
:foreign-credentials="foreignCredentials"
|
||||
:read-only="readOnly"
|
||||
:block-u-i="blockUi && showTriggerPanel"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import type {
|
||||
INodeTypeDescription,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
NodeConnectionType,
|
||||
NodeParameterValue,
|
||||
INodeCredentialDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers, NodeConnectionTypes, deepCopy } from 'n8n-workflow';
|
||||
import { NodeHelpers, deepCopy } from 'n8n-workflow';
|
||||
import type {
|
||||
CurlToJSONResponse,
|
||||
INodeUi,
|
||||
@@ -49,7 +48,6 @@ const props = withDefaults(
|
||||
eventBus: EventBus;
|
||||
dragging: boolean;
|
||||
pushRef: string;
|
||||
nodeType: INodeTypeDescription | null;
|
||||
readOnly: boolean;
|
||||
foreignCredentials: string[];
|
||||
blockUI: boolean;
|
||||
@@ -134,31 +132,17 @@ const isReadOnly = computed(
|
||||
);
|
||||
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 isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
|
||||
|
||||
const isExecutable = computed(() => {
|
||||
if (props.nodeType && node.value) {
|
||||
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 isExecutable = computed(() =>
|
||||
nodeHelpers.isNodeExecutable(node.value, props.executable, props.foreignCredentials),
|
||||
);
|
||||
|
||||
const nodeTypeVersions = computed(() => {
|
||||
if (!node.value) return [];
|
||||
@@ -186,7 +170,7 @@ const executeButtonTooltip = computed(() => {
|
||||
});
|
||||
|
||||
const nodeVersionTag = computed(() => {
|
||||
if (!props.nodeType || props.nodeType.hidden) {
|
||||
if (!nodeType.value || nodeType.value.hidden) {
|
||||
return i18n.baseText('nodeSettings.deprecated');
|
||||
}
|
||||
|
||||
@@ -200,11 +184,11 @@ const nodeVersionTag = computed(() => {
|
||||
});
|
||||
|
||||
const parameters = computed(() => {
|
||||
if (props.nodeType === null) {
|
||||
if (nodeType.value === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.nodeType?.properties ?? [];
|
||||
return nodeType.value?.properties ?? [];
|
||||
});
|
||||
|
||||
const parametersSetting = computed(() => parameters.value.filter((item) => item.isNodeSetting));
|
||||
@@ -219,7 +203,7 @@ const parametersNoneSetting = computed(() => {
|
||||
const isDisplayingCredentials = computed(
|
||||
() =>
|
||||
credentialsStore
|
||||
.getCredentialTypesNodeDescriptions('', props.nodeType)
|
||||
.getCredentialTypesNodeDescriptions('', nodeType.value)
|
||||
.filter((credentialTypeDescription) => displayCredentials(credentialTypeDescription)).length >
|
||||
0,
|
||||
);
|
||||
@@ -292,19 +276,19 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||
};
|
||||
emit('valueChanged', sendData);
|
||||
} else if (parameterData.name === 'parameters') {
|
||||
const nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion);
|
||||
if (!nodeType) {
|
||||
const _nodeType = nodeTypesStore.getNodeType(_node.type, _node.typeVersion);
|
||||
if (!_nodeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get only the parameters which are different to the defaults
|
||||
let nodeParameters = NodeHelpers.getNodeParameters(
|
||||
nodeType.properties,
|
||||
_nodeType.properties,
|
||||
_node.parameters,
|
||||
false,
|
||||
false,
|
||||
_node,
|
||||
nodeType,
|
||||
_nodeType,
|
||||
);
|
||||
|
||||
const oldNodeParameters = Object.assign({}, nodeParameters);
|
||||
@@ -321,7 +305,7 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||
parameterName,
|
||||
newValue,
|
||||
nodeParameters,
|
||||
nodeType,
|
||||
_nodeType,
|
||||
_node.typeVersion,
|
||||
);
|
||||
|
||||
@@ -337,12 +321,12 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||
// Get the parameters with the now new defaults according to the
|
||||
// from the user actually defined parameters
|
||||
nodeParameters = NodeHelpers.getNodeParameters(
|
||||
nodeType.properties,
|
||||
_nodeType.properties,
|
||||
nodeParameters as INodeParameters,
|
||||
true,
|
||||
false,
|
||||
_node,
|
||||
nodeType,
|
||||
_nodeType,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(nodeParameters as object)) {
|
||||
@@ -581,7 +565,7 @@ const setNodeValues = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.nodeType !== null) {
|
||||
if (nodeType.value !== null) {
|
||||
nodeValid.value = true;
|
||||
|
||||
const foundNodeSettings = [];
|
||||
@@ -725,7 +709,7 @@ onMounted(() => {
|
||||
setNodeValues();
|
||||
props.eventBus?.on('openSettings', openSettings);
|
||||
if (node.value !== null) {
|
||||
nodeHelpers.updateNodeParameterIssues(node.value, props.nodeType);
|
||||
nodeHelpers.updateNodeParameterIssues(node.value, nodeType.value);
|
||||
}
|
||||
importCurlEventBus.on('setHttpNodeParameters', setHttpNodeParameters);
|
||||
ndvEventBus.on('updateParameterValue', valueChanged);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import NodeSettings from '@/components/NodeSettings.vue';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { type IUpdateInformation } from '@/Interface';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { computed } from 'vue';
|
||||
@@ -12,17 +11,10 @@ const { nodeId, noWheel } = defineProps<{ nodeId: string; noWheel?: boolean }>()
|
||||
defineSlots<{ actions?: {} }>();
|
||||
|
||||
const settingsEventBus = createEventBus();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const { renameNode } = useCanvasOperations();
|
||||
|
||||
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) {
|
||||
if (parameterData.name === 'name' && parameterData.oldValue) {
|
||||
@@ -36,7 +28,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
|
||||
:event-bus="settingsEventBus"
|
||||
:dragging="false"
|
||||
:active-node="activeNode"
|
||||
:node-type="activeNodeType"
|
||||
push-ref=""
|
||||
:foreign-credentials="[]"
|
||||
:read-only="false"
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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 { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { createTestNode } from '@/__tests__/mocks';
|
||||
import { createTestNode, createMockEnterpriseSettings } from '@/__tests__/mocks';
|
||||
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 { mock } from 'vitest-mock-extended';
|
||||
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()', () => {
|
||||
beforeAll(() => {
|
||||
@@ -18,6 +28,293 @@ describe('useNodeHelpers()', () => {
|
||||
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', () => {
|
||||
test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => {
|
||||
const nodeValues = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ref } from 'vue';
|
||||
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 type {
|
||||
@@ -26,6 +30,7 @@ import type {
|
||||
NodeConnectionType,
|
||||
IRunExecutionData,
|
||||
NodeHint,
|
||||
INodeCredentials,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
@@ -47,6 +52,7 @@ import { EnableNodeToggleCommand } from '@/models/history';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import { hasPermission } from '@/utils/rbac/permissions';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
declare namespace HttpRequestNode {
|
||||
namespace V2 {
|
||||
@@ -63,6 +69,7 @@ export function useNodeHelpers() {
|
||||
const historyStore = useHistoryStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const i18n = useI18n();
|
||||
const canvasStore = useCanvasStore();
|
||||
|
||||
@@ -92,6 +99,70 @@ export function useNodeHelpers() {
|
||||
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) {
|
||||
return get(nodeValues, path ? path + '.' + parameterName : parameterName);
|
||||
}
|
||||
@@ -1005,6 +1076,8 @@ export function useNodeHelpers() {
|
||||
return {
|
||||
hasProxyAuth,
|
||||
isCustomApiCallSelected,
|
||||
isNodeExecutable,
|
||||
getForeignCredentialsIfSharingEnabled,
|
||||
getParameterValue,
|
||||
displayParameter,
|
||||
getNodeIssues,
|
||||
|
||||
Reference in New Issue
Block a user