feat(MCP Client Tool Node): Add multiple headers authentication option (#21435)

Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
RomanDavydchuk 2025-11-17 10:35:24 +02:00 committed by GitHub
parent 7271cb3174
commit 2a623eacf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 926 additions and 113 deletions

View File

@ -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'],
},
},
},

View File

@ -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<IExecuteFunctions>();
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<IExecuteFunctions>();
@ -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<IExecuteFunctions>();

View File

@ -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';

View File

@ -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<string, string>,
),
};
}
case 'none':
default: {
return {};

View File

@ -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<CredentialsEntity>({
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<CredentialsEntity>({
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<CredentialsEntity>({
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<CredentialsEntity>({
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', () => {

View File

@ -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) {

View File

@ -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' });
});
});
});

View File

@ -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);
}
}

View File

@ -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() {

View File

@ -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);
}
</script>
@ -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' }"

View File

@ -36,6 +36,7 @@ export type Props = {
path: string;
values?: Record<string, INodeParameters[]>;
isReadOnly?: boolean;
hiddenIssuesInputs?: string[];
};
type ValueChangedEvent = {
@ -47,6 +48,7 @@ type ValueChangedEvent = {
const props = withDefaults(defineProps<Props>(), {
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"
/>
</Suspense>
@ -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"
/>
</div>

View File

@ -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');
});
});
});
});

View File

@ -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<Props>(), {
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<string, INodeParameters[]>;
});
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 {
</script>
<template>
<N8nInputLabel
:label="i18n.credText(activeCredentialType).inputLabelDisplayName(parameter)"
:tooltip-text="i18n.credText(activeCredentialType).inputLabelDescription(parameter)"
:required="parameter.required"
:show-tooltip="focused"
:show-options="menuExpanded"
:data-test-id="parameter.name"
:size="label.size"
>
<template #options>
<ParameterOptions
<div>
<N8nInputLabel
:label="i18n.credText(activeCredentialType).inputLabelDisplayName(parameter)"
:tooltip-text="i18n.credText(activeCredentialType).inputLabelDescription(parameter)"
:required="parameter.required"
:show-tooltip="focused"
:show-options="menuExpanded"
:data-test-id="parameter.name"
:size="label.size"
>
<template #options>
<ParameterOptions
:parameter="parameter"
:value="value"
:is-read-only="false"
:show-options="!isFixedCollectionType"
:show-expression-selector="!isFixedCollectionType"
:is-value-expression="isValueExpression"
@update:model-value="optionSelected"
@menu-expanded="onMenuExpanded"
/>
</template>
<ParameterInputWrapper
v-if="!isFixedCollectionType"
ref="param"
input-size="large"
:parameter="parameter"
:value="value"
:is-read-only="false"
:show-options="true"
:is-value-expression="isValueExpression"
@update:model-value="optionSelected"
@menu-expanded="onMenuExpanded"
:model-value="value"
:path="parameter.name"
:hide-issues="true"
:documentation-url="documentationUrl"
:error-highlight="showRequiredErrors"
:is-for-credential="true"
:event-source="eventSource"
:hint="!showRequiredErrors && hint ? hint : ''"
:event-bus="eventBus"
@focus="onFocus"
@blur="onBlur"
@text-input="valueChanged"
@update="valueChanged"
/>
<div v-if="showRequiredErrors" :class="$style.errors">
<N8nText color="danger" size="small">
{{ i18n.baseText('parameterInputExpanded.thisFieldIsRequired') }}
<N8nLink
v-if="documentationUrl"
:to="documentationUrl"
size="small"
:underline="true"
@click="onDocumentationUrlClick"
>
{{ i18n.baseText('parameterInputExpanded.openDocs') }}
</N8nLink>
</N8nText>
</div>
</N8nInputLabel>
<div
v-if="isFixedCollectionType"
class="fixed-collection-wrapper"
data-test-id="fixed-collection-wrapper"
>
<LazyFixedCollectionParameter
:parameter="parameter"
:values="fixedCollectionValues"
:node-values="nodeValues"
:path="parameter.name"
:hidden-issues-inputs="hiddenIssuesInputs"
@value-changed="valueChanged"
/>
</template>
<ParameterInputWrapper
ref="param"
input-size="large"
:parameter="parameter"
:model-value="value"
:path="parameter.name"
:hide-issues="true"
:documentation-url="documentationUrl"
:error-highlight="showRequiredErrors"
:is-for-credential="true"
:event-source="eventSource"
:hint="!showRequiredErrors && hint ? hint : ''"
:event-bus="eventBus"
@focus="onFocus"
@blur="onBlur"
@text-input="valueChanged"
@update="valueChanged"
/>
<div v-if="showRequiredErrors" :class="$style.errors">
<N8nText color="danger" size="small">
{{ i18n.baseText('parameterInputExpanded.thisFieldIsRequired') }}
<N8nLink
v-if="documentationUrl"
:to="documentationUrl"
size="small"
:underline="true"
@click="onDocumentationUrlClick"
>
{{ i18n.baseText('parameterInputExpanded.openDocs') }}
</N8nLink>
</N8nText>
</div>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
@ -178,3 +247,21 @@ function onDocumentationUrlClick(): void {
margin-top: var(--spacing--4xs);
}
</style>
<style lang="scss">
.fixed-collection-wrapper {
.icon-button {
position: absolute;
opacity: 0;
top: -3px;
left: calc(-0.5 * var(--spacing--xs));
transition: opacity 100ms ease-in;
Button {
color: var(--icon--color);
}
}
.icon-button > Button:hover {
color: var(--icon--color--hover);
}
}
</style>

View File

@ -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,

View File

@ -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<string, string>,
);
const newRequestOptions = {
...requestOptions,
headers: {
...requestOptions.headers,
...headers,
},
};
return await Promise.resolve(newRequestOptions);
};
}

View File

@ -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",