diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts index 8948957f13b..ce8e43cedbc 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/McpClientTool.node.ts @@ -160,6 +160,15 @@ export class McpClientTool implements INodeType { }, }, }, + { + name: 'httpMultipleHeadersAuth', + required: true, + displayOptions: { + show: { + authentication: ['multipleHeadersAuth'], + }, + }, + }, { name: 'mcpOAuth2Api', required: true, @@ -247,10 +256,6 @@ export class McpClientTool implements INodeType { name: 'authentication', type: 'options', options: [ - { - name: 'MCP OAuth2', - value: 'mcpOAuth2Api', - }, { name: 'Bearer Auth', value: 'bearerAuth', @@ -259,6 +264,14 @@ export class McpClientTool implements INodeType { name: 'Header Auth', value: 'headerAuth', }, + { + name: 'MCP OAuth2', + value: 'mcpOAuth2Api', + }, + { + name: 'Multiple Headers Auth', + value: 'multipleHeadersAuth', + }, { name: 'None', value: 'none', @@ -279,7 +292,7 @@ export class McpClientTool implements INodeType { default: '', displayOptions: { show: { - authentication: ['headerAuth', 'bearerAuth', 'mcpOAuth2Api'], + authentication: ['headerAuth', 'bearerAuth', 'mcpOAuth2Api', 'multipleHeadersAuth'], }, }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/utils.test.ts index 1913d9bbf2f..e9a552286a2 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/utils.test.ts @@ -113,6 +113,22 @@ describe('utils', () => { expect(result).toEqual({ headers: { Authorization: 'Bearer access-token' } }); }); + it('should return the headers for multipleHeadersAuth', async () => { + const ctx = mockDeep(); + ctx.getCredentials.mockResolvedValue({ + headers: { + values: [ + { name: 'Foo', value: 'bar' }, + { name: 'Test', value: '123' }, + ], + }, + }); + + const result = await getAuthHeaders(ctx, 'multipleHeadersAuth'); + + expect(result).toEqual({ headers: { Foo: 'bar', Test: '123' } }); + }); + it('should return an empty object for none', async () => { const ctx = mockDeep(); @@ -129,7 +145,12 @@ describe('utils', () => { expect(result).toEqual({}); }); - it.each(['headerAuth', 'bearerAuth', 'mcpOAuth2Api'] as McpAuthenticationOption[])( + it.each([ + 'headerAuth', + 'bearerAuth', + 'mcpOAuth2Api', + 'multipleHeadersAuth', + ] as McpAuthenticationOption[])( 'should return an empty object for %s when it fails', async (authentication) => { const ctx = mockDeep(); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts index 3f318550456..f4902b733e8 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/types.ts @@ -6,4 +6,9 @@ export type McpServerTransport = 'sse' | 'httpStreamable'; export type McpToolIncludeMode = 'all' | 'selected' | 'except'; -export type McpAuthenticationOption = 'none' | 'headerAuth' | 'bearerAuth' | 'mcpOAuth2Api'; +export type McpAuthenticationOption = + | 'none' + | 'headerAuth' + | 'bearerAuth' + | 'mcpOAuth2Api' + | 'multipleHeadersAuth'; diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts index 72644cabc7e..ca2b3f0b2f6 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/utils.ts @@ -292,6 +292,25 @@ export async function getAuthHeaders( return { headers: { Authorization: `Bearer ${result.oauthTokenData.access_token}` } }; } + case 'multipleHeadersAuth': { + const result = await ctx + .getCredentials<{ headers: { values: Array<{ name: string; value: string }> } }>( + 'httpMultipleHeadersAuth', + ) + .catch(() => null); + + if (!result) return {}; + + return { + headers: result.headers.values.reduce( + (acc, cur) => { + acc[cur.name] = cur.value; + return acc; + }, + {} as Record, + ), + }; + } case 'none': default: { return {}; diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index e5148f3be4b..04fd20c26f5 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -75,6 +75,280 @@ describe('CredentialsService', () => { csrfSecret: CREDENTIAL_BLANKING_VALUE, }); }); + + it('should redact sensitive values in a fixed collection with multiple values', () => { + const fixedCollectionCredType = { + properties: [ + { + name: 'headers', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + options: [ + { + displayName: 'Header', + name: 'values', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + ], + }, + ], + } as unknown as ICredentialType; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + const decryptedData = { + headers: { + values: [ + { + name: 'Authorization', + value: 'Bearer sensitiveSecret', + }, + { + name: 'Test', + value: '123', + }, + ], + }, + }; + credentialTypes.getByName + .calledWith(credential.type) + .mockReturnValueOnce(fixedCollectionCredType); + + const redactedData = service.redact(decryptedData, credential); + + expect(redactedData).toEqual({ + headers: { + values: [ + { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE }, + { name: 'Test', value: CREDENTIAL_BLANKING_VALUE }, + ], + }, + }); + }); + + it('should redact sensitive values in a fixed collection with single value', () => { + const fixedCollectionCredType = { + properties: [ + { + name: 'headers', + type: 'fixedCollection', + typeOptions: { multipleValues: false }, + options: [ + { + displayName: 'Header', + name: 'values', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + ], + }, + ], + } as unknown as ICredentialType; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + const decryptedData = { + headers: { + values: { + name: 'Authorization', + value: 'Bearer sensitiveSecret', + }, + }, + }; + credentialTypes.getByName + .calledWith(credential.type) + .mockReturnValueOnce(fixedCollectionCredType); + + const redactedData = service.redact(decryptedData, credential); + + expect(redactedData).toEqual({ + headers: { + values: { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE }, + }, + }); + }); + + it('should redact sensitive values in a fixed collection with multiple options', () => { + const fixedCollectionCredType = { + properties: [ + { + name: 'headers', + type: 'fixedCollection', + typeOptions: { multipleValues: true }, + options: [ + { + displayName: 'Header', + name: 'values1', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + { + displayName: 'Header', + name: 'values2', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + ], + }, + ], + } as unknown as ICredentialType; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + const decryptedData = { + headers: { + values1: [ + { + name: 'Authorization', + value: 'Bearer sensitiveSecret', + }, + { + name: 'Test', + value: '123', + }, + ], + values2: [ + { + name: 'Foo', + value: 'Bar', + }, + { + name: 'Baz', + value: 'Qux', + }, + ], + }, + }; + credentialTypes.getByName + .calledWith(credential.type) + .mockReturnValueOnce(fixedCollectionCredType); + + const redactedData = service.redact(decryptedData, credential); + + expect(redactedData).toEqual({ + headers: { + values1: [ + { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE }, + { name: 'Test', value: CREDENTIAL_BLANKING_VALUE }, + ], + values2: [ + { name: 'Foo', value: CREDENTIAL_BLANKING_VALUE }, + { name: 'Baz', value: CREDENTIAL_BLANKING_VALUE }, + ], + }, + }); + }); + + it('should redact sensitive values in a fixed collection with multiple options and a single value', () => { + const fixedCollectionCredType = { + properties: [ + { + name: 'headers', + type: 'fixedCollection', + typeOptions: { multipleValues: false }, + options: [ + { + displayName: 'Header', + name: 'values1', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + { + displayName: 'Header', + name: 'values2', + values: [ + { + name: 'name', + type: 'string', + }, + { + name: 'value', + type: 'string', + typeOptions: { password: true }, + }, + ], + }, + ], + }, + ], + } as unknown as ICredentialType; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + const decryptedData = { + headers: { + values2: { + name: 'Authorization', + value: 'Bearer sensitiveSecret', + }, + }, + }; + credentialTypes.getByName + .calledWith(credential.type) + .mockReturnValueOnce(fixedCollectionCredType); + + const redactedData = service.redact(decryptedData, credential); + + expect(redactedData).toEqual({ + headers: { + values2: { name: 'Authorization', value: CREDENTIAL_BLANKING_VALUE }, + }, + }); + }); }); describe('decrypt', () => { diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index d9be637e0cc..f52f2f6349b 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -23,9 +23,17 @@ import type { ICredentialDataDecryptedObject, ICredentialsDecrypted, ICredentialType, + IDataObject, INodeProperties, + INodePropertyCollection, +} from 'n8n-workflow'; +import { + CREDENTIAL_EMPTY_VALUE, + deepCopy, + isINodePropertyCollection, + NodeHelpers, + UnexpectedError, } from 'n8n-workflow'; -import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers, UnexpectedError } from 'n8n-workflow'; import { CredentialsFinderService } from './credentials-finder.service'; @@ -506,34 +514,63 @@ export class CredentialsService { return props; }; const properties = getExtendedProps(credType); + return this.redactValues(copiedData, properties); + } - for (const dataKey of Object.keys(copiedData)) { + private redactValues(data: ICredentialDataDecryptedObject, props: INodeProperties[]) { + for (const dataKey of Object.keys(data)) { // The frontend only cares that this value isn't falsy. if (dataKey === 'oauthTokenData' || dataKey === 'csrfSecret') { - if (copiedData[dataKey].toString().length > 0) { - copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + if (data[dataKey].toString().length > 0) { + data[dataKey] = CREDENTIAL_BLANKING_VALUE; } else { - copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; + data[dataKey] = CREDENTIAL_EMPTY_VALUE; } continue; } - const prop = properties.find((v) => v.name === dataKey); + + const prop = props.find((v) => v.name === dataKey); if (!prop) { continue; } + + if (prop.type === 'fixedCollection' && prop.options?.length) { + const dataObject = data[dataKey] as IDataObject; + for (const option of prop.options) { + if (isINodePropertyCollection(option)) { + this.redactCollectionOption(dataObject, option); + } + } + } + if ( prop.typeOptions?.password && - (!(copiedData[dataKey] as string).startsWith('={{') || prop.noDataExpression) + (!(data[dataKey] as string).startsWith('={{') || prop.noDataExpression) ) { - if (copiedData[dataKey].toString().length > 0) { - copiedData[dataKey] = CREDENTIAL_BLANKING_VALUE; + if (data[dataKey].toString().length > 0) { + data[dataKey] = CREDENTIAL_BLANKING_VALUE; } else { - copiedData[dataKey] = CREDENTIAL_EMPTY_VALUE; + data[dataKey] = CREDENTIAL_EMPTY_VALUE; } } } - return copiedData; + return data; + } + + private redactCollectionOption(data: IDataObject, option: INodePropertyCollection) { + const collectionValuesKey = option.name; + const values = data?.[collectionValuesKey]; + if (Array.isArray(values)) { + for (let i = 0; i < values.length; i++) { + values[i] = this.redactValues(values[i] as ICredentialDataDecryptedObject, option.values); + } + } else if (typeof values === 'object' && values !== null) { + data[collectionValuesKey] = this.redactValues( + values as ICredentialDataDecryptedObject, + option.values, + ); + } } private unredactRestoreValues(unmerged: any, replacement: any) { diff --git a/packages/frontend/editor-ui/src/app/utils/parameterUtils.test.ts b/packages/frontend/editor-ui/src/app/utils/parameterUtils.test.ts new file mode 100644 index 00000000000..6d708b19d17 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/utils/parameterUtils.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { setParameterValue } from './parameterUtils'; + +describe('parameterUtils', () => { + describe('setParameterValue', () => { + it('should set a simple value', () => { + const target = { foo: 'bar' }; + setParameterValue(target, 'foo', 'baz'); + expect(target.foo).toBe('baz'); + }); + + it('should set a nested value', () => { + const target = { foo: { bar: 'baz' } }; + setParameterValue(target, 'foo.bar', 'qux'); + expect(target.foo.bar).toBe('qux'); + }); + + it('should unset a value when undefined', () => { + const target = { foo: 'bar', baz: 'qux' }; + setParameterValue(target, 'foo', undefined); + expect(target).toEqual({ baz: 'qux' }); + }); + + it('should delete array item when path ends with index', () => { + const target = { items: ['a', 'b', 'c'] }; + setParameterValue(target, 'items[1]', undefined); + expect(target.items).toEqual(['a', 'c']); + }); + + it('should delete nested array item', () => { + const target = { + headers: { + values: [ + { name: 'foo', value: 'bar' }, + { name: 'baz', value: 'qux' }, + { name: 'test', value: '123' }, + ], + }, + }; + setParameterValue(target, 'headers.values[1]', undefined); + expect(target.headers.values).toEqual([ + { name: 'foo', value: 'bar' }, + { name: 'test', value: '123' }, + ]); + }); + + it('should update array item when value is provided', () => { + const target = { items: ['a', 'b', 'c'] }; + setParameterValue(target, 'items[1]', 'updated'); + expect(target.items).toEqual(['a', 'updated', 'c']); + }); + + it('should handle non-existent paths gracefully', () => { + const target = {}; + setParameterValue(target, 'nonexistent[0]', undefined); + expect(target).toEqual({}); + }); + + it('should handle paths with brackets that are not arrays', () => { + const target = { foo: 'bar' }; + setParameterValue(target, 'foo[abc]', undefined); + expect(target).toEqual({ foo: 'bar' }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/utils/parameterUtils.ts b/packages/frontend/editor-ui/src/app/utils/parameterUtils.ts new file mode 100644 index 00000000000..46991f6852e --- /dev/null +++ b/packages/frontend/editor-ui/src/app/utils/parameterUtils.ts @@ -0,0 +1,42 @@ +import get from 'lodash/get'; +import set from 'lodash/set'; +import unset from 'lodash/unset'; +import type { ICredentialDataDecryptedObject, INodeParameters } from 'n8n-workflow'; + +const ARRAY_PATH_PATTERN = /(.*)\[(\d+)\]$/; + +/** + * Sets a parameter value at the given path, handling array deletions when value is undefined. + * For array paths like 'items[1]', passing undefined will remove that array item. + * For non-array paths, passing undefined will delete the property. + */ +export function setParameterValue( + parameters: INodeParameters | ICredentialDataDecryptedObject | null, + path: string, + value: unknown, +): void { + if (!parameters) return; + + const arrayPathMatch = path.match(ARRAY_PATH_PATTERN); + + if (value === undefined && arrayPathMatch) { + deleteArrayItem(parameters, arrayPathMatch[1], parseInt(arrayPathMatch[2], 10)); + } else if (value === undefined) { + unset(parameters, path); + } else { + set(parameters, path, value); + } +} + +function deleteArrayItem( + parameters: INodeParameters | ICredentialDataDecryptedObject, + arrayPath: string, + index: number, +): void { + const array = get(parameters, arrayPath); + + if (Array.isArray(array)) { + array.splice(index, 1); + set(parameters, arrayPath, array); + } +} diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index ebbeae98a64..9e4a7d17249 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -13,7 +13,7 @@ import type { INodeProperties, ITelemetryTrackProperties, } from 'n8n-workflow'; -import { NodeHelpers } from 'n8n-workflow'; +import { deepCopy, NodeHelpers } from 'n8n-workflow'; import CredentialIcon from '../CredentialIcon.vue'; import CredentialConfig from './CredentialConfig.vue'; @@ -65,6 +65,8 @@ import { type IMenuItem, } from '@n8n/design-system'; import { injectWorkflowState } from '@/app/composables/useWorkflowState'; +import { setParameterValue } from '@/app/utils/parameterUtils'; +import get from 'lodash/get'; type Props = { modalName: string; @@ -575,17 +577,17 @@ function onChangeSharedWith(sharedWithProjects: ProjectSharingData[]) { } function onDataChange({ name, value }: IUpdateInformation) { - // skip update if new value matches the current - if (credentialData.value[name] === value) return; + const currentValue = get(credentialData.value, name); + if (currentValue === value) { + return; + } hasUnsavedChanges.value = true; const { oauthTokenData, ...credData } = credentialData.value; + credentialData.value = deepCopy(credData); - credentialData.value = { - ...credData, - [name]: value as CredentialInformation, - }; + setParameterValue(credentialData.value, name, value); } function closeDialog() { diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialInputs.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialInputs.vue index 4a23688877b..1fdb26db9ec 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialInputs.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialInputs.vue @@ -27,12 +27,7 @@ const emit = defineEmits<{ }>(); function valueChanged(parameterData: IUpdateInformation) { - const name = parameterData.name.split('.').pop() ?? parameterData.name; - - emit('update', { - name, - value: parameterData.value, - }); + emit('update', parameterData); } @@ -51,6 +46,7 @@ function valueChanged(parameterData: IUpdateInformation) { v-else :parameter="parameter" :value="credentialDataValues[parameter.name]" + :node-values="credentialDataValues" :documentation-url="documentationUrl" :show-validation-warnings="showValidationWarnings" :label="{ size: 'medium' }" diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/FixedCollectionParameter.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/FixedCollectionParameter.vue index 9cfce9c1c10..a99708fc8a3 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/FixedCollectionParameter.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/FixedCollectionParameter.vue @@ -36,6 +36,7 @@ export type Props = { path: string; values?: Record; isReadOnly?: boolean; + hiddenIssuesInputs?: string[]; }; type ValueChangedEvent = { @@ -47,6 +48,7 @@ type ValueChangedEvent = { const props = withDefaults(defineProps(), { values: () => ({}), isReadOnly: false, + hiddenIssuesInputs: () => [], }); const emit = defineEmits<{ @@ -310,6 +312,7 @@ function getItemKey(item: INodeParameters, property: INodePropertyCollection) { :path="getPropertyPath(property.name, index)" :hide-delete="true" :is-read-only="isReadOnly" + :hidden-issues-inputs="hiddenIssuesInputs" @value-changed="valueChanged" /> @@ -338,6 +341,7 @@ function getItemKey(item: INodeParameters, property: INodePropertyCollection) { :is-read-only="isReadOnly" class="parameter-item" :hide-delete="true" + :hidden-issues-inputs="hiddenIssuesInputs" @value-changed="valueChanged" /> diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.test.ts new file mode 100644 index 00000000000..1bf187d5aa4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.test.ts @@ -0,0 +1,200 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import { STORES } from '@n8n/stores'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; +import { createTestNodeProperties } from '@/__tests__/mocks'; +import ParameterInputExpanded from './ParameterInputExpanded.vue'; +import type { INodePropertyCollection } from 'n8n-workflow'; +import userEvent from '@testing-library/user-event'; +import { nextTick } from 'vue'; + +vi.mock('@/app/composables/useTelemetry', () => ({ + useTelemetry: () => ({ + track: vi.fn(), + }), +})); + +vi.mock('@n8n/i18n', () => ({ + i18n: { + baseText: vi.fn().mockImplementation((key) => key), + credText: vi.fn().mockReturnValue({ + inputLabelDisplayName: () => 'Test label', + inputLabelDescription: () => 'Test description', + hint: () => 'Test hint', + placeholder: () => 'Test placeholder', + }), + nodeText: vi.fn().mockReturnValue({ + inputLabelDisplayName: () => 'Test label', + inputLabelDescription: () => 'Test description', + hint: () => 'Test hint', + placeholder: () => 'Test placeholder', + }), + }, + i18nInstance: { + global: { + t: vi.fn().mockImplementation((key) => key), + }, + }, + useI18n: vi.fn().mockReturnValue({ + baseText: vi.fn().mockImplementation((key) => key), + credText: vi.fn().mockReturnValue({ + inputLabelDisplayName: () => 'Test label', + inputLabelDescription: () => 'Test description', + hint: () => 'Test hint', + placeholder: () => 'Test placeholder', + }), + nodeText: vi.fn().mockReturnValue({ + inputLabelDisplayName: () => 'Test label', + inputLabelDescription: () => 'Test description', + hint: () => 'Test hint', + placeholder: () => 'Test placeholder', + }), + }), +})); + +describe('ParameterInputExpanded.vue', () => { + const mockPinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE, + [STORES.UI]: { + activeCredentialType: 'testCred', + }, + [STORES.WORKFLOWS]: { + workflowId: 'test-workflow', + }, + }, + }); + + const renderComponent = createComponentRenderer(ParameterInputExpanded, { + pinia: mockPinia, + }); + + describe('FixedCollectionParameter', () => { + const fixedCollectionParameter = createTestNodeProperties({ + name: 'headers', + type: 'fixedCollection', + displayName: 'Headers', + typeOptions: { + multipleValues: true, + }, + options: [ + { + name: 'values', + displayName: 'Values', + default: { values: [{ name: '', value: '' }] }, + values: [ + { + name: 'name', + type: 'string', + displayName: 'Name', + default: '', + }, + { + name: 'value', + type: 'string', + displayName: 'Value', + default: '', + }, + ], + } as INodePropertyCollection, + ], + }); + + it('should render FixedCollectionParameter for fixedCollection type', async () => { + const nodeValues = { + headers: { values: [{ name: '', value: '' }] }, + }; + const { getByTestId } = renderComponent({ + props: { + parameter: fixedCollectionParameter, + value: nodeValues.headers, + nodeValues, + }, + }); + await vi.dynamicImportSettled(); + + expect(getByTestId('fixed-collection-wrapper')).toBeInTheDocument(); + }); + + it('should update the value when the user types', async () => { + const nodeValues = { + headers: { values: [{ name: 'Content-', value: '' }] }, + }; + const { queryAllByTestId } = renderComponent({ + props: { + parameter: fixedCollectionParameter, + value: nodeValues.headers, + nodeValues, + }, + }); + await vi.dynamicImportSettled(); + const input = queryAllByTestId('parameter-input-field')[0]; + + await userEvent.type(input, 'Type'); + await nextTick(); + + expect(input).toHaveValue('Content-Type'); + }); + + it('should add a new option when the user clicks the add button', async () => { + const nodeValues = { + headers: { values: [{ name: '', value: '' }] }, + }; + const { getByTestId, queryAllByTestId } = renderComponent({ + props: { + parameter: fixedCollectionParameter, + value: nodeValues.headers, + nodeValues, + }, + }); + await vi.dynamicImportSettled(); + + const inputsBefore = queryAllByTestId('parameter-input-field'); + expect(inputsBefore.length).toBe(2); + + const button = getByTestId('fixed-collection-add'); + await userEvent.click(button); + await nextTick(); + + const inputsAfter = queryAllByTestId('parameter-input-field'); + expect(inputsAfter.length).toBe(4); + }); + + describe('ParameterInputWrapper', () => { + const parameter = createTestNodeProperties({ + name: 'test', + type: 'string', + }); + + it('should render ParameterInputWrapper for non fixedCollection type', async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + parameter, + value: 'test', + }, + }); + await vi.dynamicImportSettled(); + + expect(getByTestId('parameter-input')).toBeInTheDocument(); + // verify that the fixed collection wrapper is not rendered as it too contains `parameter-input` fields + expect(queryByTestId('fixed-collection-wrapper')).not.toBeInTheDocument(); + }); + + it('should update the value when the user types', async () => { + const { getByTestId } = renderComponent({ + props: { + parameter, + value: 'test', + }, + }); + await vi.dynamicImportSettled(); + const input = getByTestId('parameter-input-field'); + + await userEvent.type(input, '123'); + await nextTick(); + + expect(input).toHaveValue('test123'); + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.vue index 99164136549..83ccc21ac57 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInputExpanded.vue @@ -5,22 +5,30 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { isValueExpression as isValueExpressionUtil } from '@/app/utils/nodeTypesUtils'; import { createEventBus } from '@n8n/utils/event-bus'; -import type { - INodeParameterResourceLocator, - INodeProperties, - IParameterLabel, - NodeParameterValueType, +import { + isINodePropertyCollection, + type INodeParameterResourceLocator, + type INodeParameters, + type INodeProperties, + type IParameterLabel, + type NodeParameterValueType, } from 'n8n-workflow'; -import { computed, ref } from 'vue'; +import { computed, defineAsyncComponent, ref } from 'vue'; import ParameterInputWrapper from './ParameterInputWrapper.vue'; import ParameterOptions from './ParameterOptions.vue'; import { useUIStore } from '@/app/stores/ui.store'; import { storeToRefs } from 'pinia'; import { N8nInputLabel, N8nLink, N8nText } from '@n8n/design-system'; + +const LazyFixedCollectionParameter = defineAsyncComponent( + async () => await import('./FixedCollectionParameter.vue'), +); + type Props = { parameter: INodeProperties; value: NodeParameterValueType; + nodeValues?: INodeParameters; showValidationWarnings?: boolean; documentationUrl?: string; eventSource?: string; @@ -29,6 +37,9 @@ type Props = { const props = withDefaults(defineProps(), { label: () => ({ size: 'small' }), + nodeValues: () => ({}), + documentationUrl: undefined, + eventSource: undefined, }); const emit = defineEmits<{ update: [value: IUpdateInformation]; @@ -47,6 +58,37 @@ const telemetry = useTelemetry(); const { activeCredentialType } = storeToRefs(uiStore); +const isFixedCollectionType = computed(() => { + return props.parameter.type === 'fixedCollection'; +}); + +const hiddenIssuesInputs = computed(() => { + if (!isFixedCollectionType.value) { + return []; + } + + if (!props.parameter.options?.length) { + return []; + } + + const names = []; + for (const option of props.parameter.options) { + if (isINodePropertyCollection(option)) { + names.push(...option.values.map((value) => value.name)); + } + } + + return names; +}); + +const fixedCollectionValues = computed(() => { + if (!isFixedCollectionType.value || !props.value) { + return {}; + } + + return props.value as Record; +}); + const showRequiredErrors = computed(() => { if (!props.parameter.required) { return false; @@ -102,7 +144,16 @@ function optionSelected(command: string) { } function valueChanged(parameterData: IUpdateInformation) { - emit('update', parameterData); + let name = parameterData.name; + // for fixed collection, we need to keep the full path + if (!isFixedCollectionType.value) { + name = name.split('.').pop() ?? name; + } + + emit('update', { + name, + value: parameterData.value, + }); } function onDocumentationUrlClick(): void { @@ -115,59 +166,77 @@ function onDocumentationUrlClick(): void { + + diff --git a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts index e406965a0e5..4c5db67ab80 100644 --- a/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts +++ b/packages/frontend/editor-ui/src/features/ndv/shared/ndv.utils.ts @@ -28,6 +28,7 @@ import unset from 'lodash/unset'; import { captureException } from '@sentry/vue'; import { isPresent } from '@/app/utils/typesUtils'; +import { setParameterValue } from '@/app/utils/parameterUtils'; import type { Ref } from 'vue'; import { omitKey } from '@/app/utils/objectUtils'; import type { BaseTextKey } from '@n8n/i18n'; @@ -305,34 +306,11 @@ export function updateParameterByPath( nodeType: INodeTypeDescription, nodeTypeVersion: INode['typeVersion'], ) { - // Remove the 'parameters.' from the beginning to just have the - // actual parameter name const parameterPath = parameterName.split('.').slice(1).join('.'); - // Check if the path is supposed to change an array and if so get - // the needed data like path and index - const parameterPathArray = parameterPath.match(/(.*)\[(\d+)\]$/); + setParameterValue(nodeParameters, parameterPath, newValue); - // Apply the new value - if (newValue === undefined && parameterPathArray !== null) { - // Delete array item - const path = parameterPathArray[1]; - const index = parameterPathArray[2]; - const data = get(nodeParameters, path); - - if (Array.isArray(data)) { - data.splice(parseInt(index, 10), 1); - set(nodeParameters as object, path, data); - } - } else { - if (newValue === undefined) { - unset(nodeParameters as object, parameterPath); - } else { - set(nodeParameters as object, parameterPath, newValue); - } - - // If value is updated, remove parameter values that have invalid options - // so getNodeParameters checks don't fail + if (newValue !== undefined) { removeMismatchedOptionValues(nodeType, nodeTypeVersion, nodeParameters, { name: parameterPath, value: newValue, diff --git a/packages/nodes-base/credentials/HttpMultipleHeadersAuth.credentials.ts b/packages/nodes-base/credentials/HttpMultipleHeadersAuth.credentials.ts new file mode 100644 index 00000000000..78e2a26d6d0 --- /dev/null +++ b/packages/nodes-base/credentials/HttpMultipleHeadersAuth.credentials.ts @@ -0,0 +1,69 @@ +/* eslint-disable n8n-nodes-base/cred-class-name-unsuffixed */ +/* eslint-disable n8n-nodes-base/cred-class-field-name-unsuffixed */ +import type { IAuthenticate, ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; + +export class HttpMultipleHeadersAuth implements ICredentialType { + name = 'httpMultipleHeadersAuth'; + + displayName = 'Multiple Headers Auth'; + + documentationUrl = 'httprequest'; + + icon: Icon = 'node:n8n-nodes-base.httpRequest'; + + properties: INodeProperties[] = [ + { + displayName: 'Headers', + name: 'headers', + type: 'fixedCollection', + default: { values: [{ name: '', value: '' }] }, + typeOptions: { + multipleValues: true, + }, + placeholder: 'Add Header', + options: [ + { + displayName: 'Header', + name: 'values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + typeOptions: { + password: true, + }, + }, + ], + }, + ], + }, + ]; + + authenticate: IAuthenticate = async (credentials, requestOptions) => { + const values = (credentials.headers as { values: Array<{ name: string; value: string }> }) + .values; + const headers = values.reduce( + (acc, cur) => { + acc[cur.name] = cur.value; + return acc; + }, + {} as Record, + ); + const newRequestOptions = { + ...requestOptions, + headers: { + ...requestOptions.headers, + ...headers, + }, + }; + return await Promise.resolve(newRequestOptions); + }; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 2675a01a13b..40ada196e21 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -174,6 +174,7 @@ "dist/credentials/HttpBearerAuth.credentials.js", "dist/credentials/HttpDigestAuth.credentials.js", "dist/credentials/HttpHeaderAuth.credentials.js", + "dist/credentials/HttpMultipleHeadersAuth.credentials.js", "dist/credentials/HttpCustomAuth.credentials.js", "dist/credentials/HttpQueryAuth.credentials.js", "dist/credentials/HttpSslAuth.credentials.js",