mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(MCP Client Tool Node): Add multiple headers authentication option (#21435)
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
parent
7271cb3174
commit
2a623eacf3
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
42
packages/frontend/editor-ui/src/app/utils/parameterUtils.ts
Normal file
42
packages/frontend/editor-ui/src/app/utils/parameterUtils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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' }"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user