feat(core): Allow credential reuse on HTTP Request node (#3228)

*  Create controller

*  Mount controller

* ✏️ Add error messages

*  Create scopes fetcher

*  Account for non-existent credential type

* 📘 Type scopes request

*  Adjust error message

* 🧪 Add tests

*  Introduce simple node versioning

*  Add example how to read version in node-code for custom logic

* 🐛 Fix setting of parameters

* 🐛 Fix another instance where it sets the wrong parameter

*  Remove unnecessary TOODs

*  Re-version HTTP Request node

* 👕 Satisfy linter

*  Retrieve node version

*  Undo Jan's changes to Set node

* 🧪 Fix CI/CD for `/oauth2-credential` tests (#3230)

* 🐛 Fix notice warning missing background color (#3231)

* 🐛 Check for generic auth in node cred types

*  Refactor credentials dropdown for HTTP Request node (#3222)

*  Discoverability flow (#3229)

*  Added node credentials type proxy. Changed node credentials input order.

*  Add computed property from versioning branch

* 🐛 Fix cred ref lost and unsaved

*  Make options consistent with cred type names

*  Use prop to set component order

*  Use constant and version

*  Fix rendering for generic auth creds

*  Mark as required on first selection

*  Implement discoverability flow

*  Mark as required on subsequent selections

*  Fix marking as required after cred deletion

*  Refactor to clean up

*  Detect position automatically

*  Add i18n to option label

*  Hide subtitle for custom action

*  Detect active credential type

*  Prop drilling to re-render select

* 🔥 Remove unneeded property

* ✏️ Rename arg

* 🔥 Remove unused import

* 🔥 Remove unneeded getters

* 🔥 Remove unused import

*  Generalize cred component positioning

*  Set up request

* 🐛 Fix edge case in endpoint

*  Display scopes alert box

*  Revert "Generalize cred comp positioning"

This reverts commit 75eea89273.

*  Consolidate HTTPRN check

*  Fix hue percentage to degree

* 🔥 Remove unused import

* 🔥 Remove unused import

* 🔥 Remove unused class

* 🔥 Remove unused import

* 📘 Create type for HTTPRN v2 auth params

* ✏️ Rename check

* 🔥 Remove unused import

* ✏️ Add i18n to `reportUnsetCredential()`

*  Refactor Alex's spacing changes

*  Post-merge fixes

*  Add docs link

* 🔥 Exclude Notion OAuth cred

* ✏️ Update copy

* ✏️ Rename param

* 🎨 Reposition notice and simplify styling

* ✏️ Update copy

* ✏️ Update copy

*  Hide params during custom action

*  Show notice if any cred type supported

* 🐛 Prevent scopes text overflow

* 🔥 Remove superfluous check

* ✏️ Break up docstring

* 🎨 Tweak notice styling

*  Reorder cred param in Webhook node

* ✏️ Shorten cred name in scopes notice

* 🧪 Update Notice snapshots

* 🐛 Fix check when `globalRole` is `undefined`

*  Revert 3f2c4a6

*  Apply feedback from Product

* 🧪 Update snapshot

*  Adjust regex expansion pattern for singular

* 🔥 Remove unused import

* 🔥 Remove logging

*  Make `somethingElse` key more unique

*  Move something else to constants

*  Consolidate notice component

*  Apply latest feedback

* 🧪 Update tests

* 🧪 Update snapshot

* ✏️ Fix singular version

* 🧪 Finalize tests

* ✏️ Rename constant

* 🧪 Expand tests

* 🔥 Remove `truncate` prop

* 🚚 Move scopes fetching to store

* 🚚 Move method to component

*  Use constant

*  Refactor `Notice` component

* 🧪 Update tests

* 🔥 Remove unused keys

*  Inject custom API call option

* 🔥 Remove unused props

* 🎨 Use `compact` prop

* 🧪 Update snapshots

* 🚚 Move scopes to store

* 🚚 Move `nodeCredentialTypes` to parent

* ✏️ Rename cred types per branding

* 🐛 Clear scopes when none

*  Add default

* 🚚 Move `newHttpRequestNodeCredentialType` to parent

* 🔥 Remove test data

*  Separate lines for readability

*  Change reference from node to node name

* ✏️ Rename i18n keys

*  Refactor OAuth check

* 🔥 Remove unused key

* 🚚 Move `OAuth1/2 API` to i18n

*  Refactor `skipCheck`

*  Add `stopPropagation` and `preventDefault`

* 🚚 Move active credential scopes logic to store

* 🎨 Fix spacing for `NodeWebhooks` component

*  Implement feedback

*  Update HTTPRN default and issue copy

* Refactor to use `CredentialsSelect` param (#3304)

*  Refactor into cred type param

*  Componentize scopes notice

* 🔥 Remove unused data

* 🔥 Remove unused `loadOptions`

*  Componentize `NodeCredentialType`

* 🐛 Fix param validation

* 🔥 Remove dup methods

*  Refactor all references to `isHttpRequestNodeV2`

* 🎨 Fix styling

* 🔥 Remove unused import

* 🔥 Remove unused properties

* 🎨 Fix spacing for Pipedrive Trigger node

* 🎨 Undo Webhook node styling change

* 🔥 Remove unused style

*  Cover `httpHeaderAuth` edge case

* 🐛 Fix `this.node` reference

* 🚚 Rename to `credentialsSelect`

* 🐛 Fix mistaken renaming

*  Set one attribute per line

*  Move condition to instantiation site

* 🚚 Rename prop

*  Refactor away `prepareScopesNotice`

* ✏️ Rename i18n keys

* ✏️ Update i18n calls

* ✏️ Add more i18n keys

* 🔥 Remove unused props

* ✏️ Add explanatory comment

*  Adjust check in `hasProxyAuth`

*  Refactor `credentialSelected` from prop to event

*  Eventify `valueChanged`, `setFocus`, `onBlur`

*  Eventify `optionSelected`

*  Add `noDataExpression`

* 🔥 Remove logging

* 🔥 Remove URL from scopes

*  Disregard expressions for display

* 🎨 Use CSS modules

* 📘 Tigthen interface

* 🐛 Fix generic auth display

* 🐛 Fix generic auth validation

* 📘 Loosen type

* 🚚 Move event params to end

*  Generalize reference

*  Refactor generic auth as `credentialsSelect` param

*  Restore check for `httpHeaderAuth `

* 🚚 Rename `existing` to `predefined`

* Extend metrics for HTTP Request node (#3282)

*  Extend metrics

* 🧪 Add tests

*  Update param names

Co-authored-by: Alex Grozav <alex@grozav.com>

*  Update check per new branch

*  Include generic auth check

*  Adjust telemetry (#3359)

*  Filter credential types by label

Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Iván Ovejero 2022-05-24 11:36:19 +02:00 committed by GitHub
parent 0212d65dae
commit 336fc9e2a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1396 additions and 228 deletions

View file

@ -3,6 +3,7 @@ import {
ICredentialTypeData,
ICredentialTypes as ICredentialTypesInterface,
} from 'n8n-workflow';
import { RESPONSE_ERROR_MESSAGES } from './constants';
class CredentialTypesClass implements ICredentialTypesInterface {
credentialTypes: ICredentialTypeData = {};
@ -16,7 +17,11 @@ class CredentialTypesClass implements ICredentialTypesInterface {
}
getByName(credentialType: string): ICredentialType {
return this.credentialTypes[credentialType].type;
try {
return this.credentialTypes[credentialType].type;
} catch (error) {
throw new Error(`${RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL}: ${credentialType}`);
}
}
}

View file

@ -1456,7 +1456,7 @@ class App {
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description);
acc.push(injectCustomApiCallOption(description));
return acc;
}, []);
}
@ -1480,7 +1480,7 @@ class App {
// ignore - no translation exists at path
}
nodeTypes.push(description);
nodeTypes.push(injectCustomApiCallOption(description));
}
const nodeTypes: INodeTypeDescription[] = [];
@ -3114,3 +3114,58 @@ async function getExecutionsCount(
return { count, estimated: false };
}
const CUSTOM_API_CALL_NAME = 'Custom API Call';
const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
/**
* Inject a `Custom API Call` option into `resource` and `operation`
* parameters in a node that supports proxy auth.
*/
function injectCustomApiCallOption(description: INodeTypeDescription) {
if (!supportsProxyAuth(description)) return description;
description.properties.forEach((p) => {
if (
['resource', 'operation'].includes(p.name) &&
Array.isArray(p.options) &&
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
) {
p.options.push({
name: CUSTOM_API_CALL_NAME,
value: CUSTOM_API_CALL_KEY,
});
}
return p;
});
return description;
}
const credentialTypes = CredentialTypes();
/**
* Whether any of the node's credential types may be used to
* make a request from a node other than itself.
*/
function supportsProxyAuth(description: INodeTypeDescription) {
if (!description.credentials) return false;
return description.credentials.some(({ name }) => {
const credType = credentialTypes.getByName(name);
if (credType.authenticate !== undefined) return true;
return isOAuth(credType);
});
}
function isOAuth(credType: ICredentialType) {
return (
Array.isArray(credType.extends) &&
credType.extends.some((parentType) =>
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
)
);
}

View file

@ -244,9 +244,7 @@ export declare namespace OAuthRequest {
namespace OAuth2Credential {
type Auth = OAuth1Credential.Auth;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }> & {
user?: User;
};
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
}
}

View file

@ -1,24 +1,14 @@
<template>
<div :id="id" :class="classes" role="alert">
<div :id="id" :class="classes" role="alert" @click=onClick>
<div class="notice-content">
<n8n-text size="small">
<n8n-text size="small" :compact="true">
<slot>
<span
:class="expanded ? $style['expanded'] : $style['truncated']"
:class="showFullContent ? $style['expanded'] : $style['truncated']"
:id="`${id}-content`"
role="region"
v-html="sanitizedContent"
v-html="sanitizeHtml(showFullContent ? fullContent : content)"
/>
<span v-if="canTruncate">
<a
role="button"
:aria-controls="`${id}-content`"
:aria-expanded="canTruncate && !expanded ? 'false' : 'true'"
@click="toggleExpanded"
>
{{ t(expanded ? 'notice.showLess' : 'notice.showMore') }}
</a>
</span>
</slot>
</n8n-text>
</div>
@ -30,9 +20,7 @@ import Vue from 'vue';
import sanitizeHtml from 'sanitize-html';
import N8nText from "../../components/N8nText";
import Locale from "../../mixins/locale";
import {uid} from "../../utils";
const DEFAULT_TRUNCATION_MAX_LENGTH = 150;
import { uid } from "../../utils";
export default Vue.extend({
name: 'n8n-notice',
@ -49,15 +37,11 @@ export default Vue.extend({
type: String,
default: 'warning',
},
truncateAt: {
type: Number,
default: 150,
},
truncate: {
type: Boolean,
default: false,
},
content: {
required: true,
type: String,
},
fullContent: {
type: String,
default: '',
},
@ -67,7 +51,7 @@ export default Vue.extend({
},
data() {
return {
expanded: false,
showFullContent: false,
};
},
computed: {
@ -79,22 +63,32 @@ export default Vue.extend({
];
},
canTruncate(): boolean {
return this.truncate && this.content.length > this.truncateAt;
},
truncatedContent(): string {
if (!this.canTruncate || this.expanded) {
return this.content;
}
return this.content.slice(0, this.truncateAt as number) + '...';
},
sanitizedContent(): string {
return sanitizeHtml(this.truncatedContent);
return this.fullContent !== undefined;
},
},
methods: {
toggleExpanded() {
this.expanded = !this.expanded;
this.showFullContent = !this.showFullContent;
},
sanitizeHtml(text: string): string {
return sanitizeHtml(
text, {
allowedAttributes: { a: ['data-key', 'href', 'target'] },
}
);
},
onClick(e) {
if (e.target.localName !== 'a') return;
if (e.target.dataset.key === 'show-less') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = false;
} else if (this.canTruncate && e.target.dataset.key === 'toggle-expand') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = !this.showFullContent;
}
},
},
});
@ -102,15 +96,17 @@ export default Vue.extend({
<style lang="scss" module>
.notice {
font-size: var(--font-size-2xs);
display: flex;
color: var(--custom-font-black);
margin: 0;
padding: var(--spacing-xs);
margin: var(--spacing-s) 0;
padding: var(--spacing-2xs);
background-color: var(--background-color);
border-width: 1px 1px 1px 7px;
border-style: solid;
border-color: var(--border-color);
border-radius: var(--border-radius-small);
line-height: var(--font-line-height-compact);
a {
font-weight: var(--font-weight-bold);

View file

@ -11,6 +11,7 @@ describe('components', () => {
slots: {
default: 'This is a notice.',
},
stubs: ['n8n-text'],
});
expect(wrapper.html()).toMatchSnapshot();
});
@ -23,28 +24,31 @@ describe('components', () => {
id: 'notice',
content: 'This is a notice.',
},
stubs: ['n8n-text'],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render html', () => {
it('should render HTML', () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
content: '<strong>Hello world!</strong> This is a notice.',
},
stubs: ['n8n-text'],
});
expect(wrapper.container.querySelectorAll('strong')).toHaveLength(1);
expect(wrapper.html()).toMatchSnapshot();
});
it('should sanitize rendered html', () => {
it('should sanitize rendered HTML', () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
content: '<script>alert(1);</script> This is a notice.',
},
stubs: ['n8n-text'],
});
expect(wrapper.container.querySelector('script')).not.toBeTruthy();
@ -52,44 +56,5 @@ describe('components', () => {
});
});
});
describe('truncation', () => {
it('should truncate content longer than 150 characters', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
expect(button).toBeVisible();
expect(button).toHaveTextContent('Show more');
expect(region).toBeVisible();
expect(region.textContent!.endsWith('...')).toBeTruthy();
});
it('should expand truncated text when clicking show more', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
await fireEvent.click(button);
expect(button).toHaveTextContent('Show less');
expect(region.textContent!.endsWith('...')).not.toBeTruthy();
});
});
});
});

View file

@ -1,28 +1,33 @@
// Vitest Snapshot v1
exports[`components > N8nNotice > props > content > should render HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"><strong>Hello world!</strong> This is a notice.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\">This is a notice.</span>
<!----></span></div>
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\">This is a notice.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8nNotice > props > content > should render html 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"><strong>Hello world!</strong> This is a notice.</span>
<!----></span></div>
</div>"
`;
exports[`components > N8nNotice > props > content > should sanitize rendered html 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"> This is a notice.</span>
<!----></span></div>
exports[`components > N8nNotice > props > content > should sanitize rendered HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"> This is a notice.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8nNotice > should render correctly 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\">This is a notice.</span></div>
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\">This is a notice.</n8n-text-stub>
</div>
</div>"
`;

View file

@ -120,7 +120,7 @@
var(--color-warning-tint-1-l)
);
--color-warning-tint-2-h: 34%;
--color-warning-tint-2-h: 34;
--color-warning-tint-2-s: 80%;
--color-warning-tint-2-l: 96%;
--color-warning-tint-2: hsl(

View file

@ -159,6 +159,9 @@ export interface IExternalHooks {
run(eventName: string, metadata?: IDataObject): Promise<void>;
}
/**
* @deprecated Do not add methods to this interface.
*/
export interface IRestApi {
getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined >;

View file

@ -0,0 +1,153 @@
<template>
<div>
<div :class="$style['parameter-value-container']">
<n8n-select
:size="inputSize"
filterable
:value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:title="displayTitle"
@change="(value) => $emit('valueChanged', value)"
@keydown.stop
@focus="$emit('setFocus')"
@blur="$emit('onBlur')"
>
<n8n-option
v-for="credType in supportedCredentialTypes"
:value="credType.name"
:key="credType.name"
:label="credType.displayName"
>
<div class="list-option">
<div class="option-headline">
{{ credType.displayName }}
</div>
<div
v-if="credType.description"
class="option-description"
v-html="credType.description"
/>
</div>
</n8n-option>
</n8n-select>
<slot name="issues-and-options" />
</div>
<scopes-notice
v-if="scopes.length > 0"
:activeCredentialType="activeCredentialType"
:scopes="scopes"
/>
<div>
<node-credentials
:node="node"
:overrideCredType="node.parameters[parameter.name]"
@credentialSelected="(updateInformation) => $emit('credentialSelected', updateInformation)"
/>
</div>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import ScopesNotice from '@/components/ScopesNotice.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
export default Vue.extend({
name: 'CredentialsSelect',
components: {
ScopesNotice,
NodeCredentials,
},
props: [
'activeCredentialType',
'node',
'parameter',
'inputSize',
'displayValue',
'isReadOnly',
'displayTitle',
],
computed: {
...mapGetters('credentials', ['allCredentialTypes', 'getScopesByCredentialType']),
scopes(): string[] {
if (!this.activeCredentialType) return [];
return this.getScopesByCredentialType(this.activeCredentialType);
},
supportedCredentialTypes(): ICredentialType[] {
return this.allCredentialTypes.filter((c: ICredentialType) => this.isSupported(c.name));
},
},
methods: {
/**
* Check if a credential type belongs to one of the supported sets defined
* in the `credentialTypes` key in a `credentialsSelect` parameter
*/
isSupported(name: string): boolean {
const supported = this.getSupportedSets(this.parameter.credentialTypes);
const checkedCredType = this.$store.getters['credentials/getCredentialTypeByName'](name);
for (const property of supported.has) {
if (checkedCredType[property] !== undefined) {
// edge case: `httpHeaderAuth` has `authenticate` auth but belongs to generic auth
if (name === 'httpHeaderAuth' && property === 'authenticate') continue;
return true;
}
}
if (
checkedCredType.extends &&
checkedCredType.extends.some(
(parentType: string) => supported.extends.includes(parentType),
)
) {
return true;
}
if (checkedCredType.extends && supported.extends.length) {
// recurse upward until base credential type
// e.g. microsoftDynamicsOAuth2Api -> microsoftOAuth2Api -> oAuth2Api
return checkedCredType.extends.reduce(
(acc: boolean, parentType: string) => acc || this.isSupported(parentType),
false,
);
}
return false;
},
getSupportedSets(credentialTypes: string[]) {
return credentialTypes.reduce<{ extends: string[]; has: string[] }>((acc, cur) => {
const _extends = cur.split('extends:');
if (_extends.length === 2) {
acc.extends.push(_extends[1]);
return acc;
}
const _has = cur.split('has:');
if (_has.length === 2) {
acc.has.push(_has[1]);
return acc;
}
return acc;
}, { extends: [], has: [] });
},
},
});
</script>
<style module lang="scss">
.parameter-value-container {
display: flex;
align-items: center;
}
</style>

View file

@ -75,7 +75,7 @@
<script lang="ts">
import Vue from 'vue';
import { WAIT_TIME_UNLIMITED } from '@/constants';
import { CUSTOM_API_CALL_KEY, WAIT_TIME_UNLIMITED } from '@/constants';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@ -336,7 +336,11 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
},
methods: {
setSubtitle() {
this.nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || '';
const nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || '';
this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY)
? ''
: nodeSubtitle;
},
disableNode () {
this.disableNodes([this.data]);

View file

@ -1,5 +1,5 @@
<template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="$style.container">
<div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="['node-credentials', $style.container]">
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name">
<n8n-input-label
:label="$locale.baseText(
@ -11,15 +11,20 @@
}
)"
:bold="false"
size="small"
:set="issues = getIssues(credentialTypeDescription.name)"
size="small"
>
<div v-if="isReadOnly">
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" />
<n8n-input
:value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name"
disabled
size="small"
/>
</div>
<div :class="issues.length ? $style.hasIssues : $style.input" v-else >
<div
v-else
:class="issues.length ? $style.hasIssues : $style.input"
>
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small">
<n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
@ -82,6 +87,7 @@ export default mixins(
name: 'NodeCredentials',
props: [
'node', // INodeUi
'overrideCredType', // cred type
],
data () {
return {
@ -92,6 +98,7 @@ export default mixins(
computed: {
...mapGetters('credentials', {
credentialOptions: 'allCredentialsByType',
getCredentialTypeByName: 'getCredentialTypeByName',
}),
credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription
@ -106,6 +113,10 @@ export default mixins(
credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi;
const credType = this.getCredentialTypeByName(this.overrideCredType);
if (credType) return [credType];
const activeNodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials;
@ -198,7 +209,15 @@ export default mixins(
return;
}
this.$telemetry.track('User selected credential from node modal', { credential_type: credentialType, workflow_id: this.$store.getters.workflowId });
this.$telemetry.track(
'User selected credential from node modal',
{
credential_type: credentialType,
node_type: this.node.type,
...(this.hasProxyAuth(this.node) ? { is_service_specific: true } : {}),
workflow_id: this.$store.getters.workflowId,
},
);
const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId);
const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {};
@ -295,11 +314,7 @@ export default mixins(
<style lang="scss" module>
.container {
margin: var(--spacing-xs) 0;
> * {
margin-bottom: var(--spacing-xs);
}
margin-top: var(--spacing-xs);
}
.warning {

View file

@ -23,14 +23,35 @@
</div>
<div class="node-parameters-wrapper" v-if="node && nodeValid">
<div v-show="openPanel === 'params'">
<node-credentials :node="node" @credentialSelected="credentialSelected"></node-credentials>
<node-webhooks :node="node" :nodeType="nodeType" />
<parameter-input-list :parameters="parametersNoneSetting" :hideDelete="true" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" />
<node-webhooks
:node="node"
:nodeType="nodeType"
/>
<parameter-input-list
:parameters="parametersNoneSetting"
:hideDelete="true"
:nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged"
>
<node-credentials
:node="node"
@credentialSelected="credentialSelected"
/>
</parameter-input-list>
<div v-if="parametersNoneSetting.length === 0" class="no-parameters">
<n8n-text>
{{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
{{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
</n8n-text>
</div>
<div v-if="isCustomApiCallSelected(nodeValues)" class="parameter-item parameter-notice">
<n8n-notice
:content="$locale.baseText(
'nodeSettings.useTheHttpRequestNode',
{ interpolate: { nodeTypeDisplayName: nodeType.displayName } }
)"
/>
</div>
</div>
<div v-show="openPanel === 'settings'">
<parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" />

View file

@ -104,12 +104,44 @@
:placeholder="parameter.placeholder"
/>
<credentials-select
v-else-if="parameter.type === 'credentialsSelect' || (parameter.name === 'genericAuthType')"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template v-slot:issues-and-options>
<parameter-issues
:issues="getIssues"
/>
<parameter-options
v-if="displayOptionsComputed"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
</template>
</credentials-select>
<n8n-select
v-else-if="parameter.type === 'options'"
ref="inputField"
:size="inputSize"
filterable
:value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@ -168,26 +200,21 @@
/>
</div>
<div class="parameter-issues" v-if="getIssues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + getIssues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<parameter-issues
v-if="parameter.type !== 'credentialsSelect'"
:issues="getIssues"
/>
<parameter-options
v-if="displayOptionsComputed && parameter.type !== 'credentialsSelect'"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
<div class="parameter-options" v-if="displayOptionsComputed">
<el-dropdown trigger="click" @command="optionSelected" size="mini">
<span class="el-dropdown-link">
<font-awesome-icon icon="cogs" class="reset-icon clickable" :title="$locale.baseText('parameterInput.parameterOptions')"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="addExpression" v-if="parameter.noDataExpression !== true && !isValueExpression">{{ $locale.baseText('parameterInput.addExpression') }}</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="parameter.noDataExpression !== true && isValueExpression">{{ $locale.baseText('parameterInput.removeExpression') }}</el-dropdown-item>
<el-dropdown-item command="refreshOptions" v-if="hasRemoteMethod">{{ $locale.baseText('parameterInput.refreshList') }}</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" :divided="!parameter.noDataExpression || hasRemoteMethod">{{ $locale.baseText('parameterInput.resetValue') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
@ -196,6 +223,7 @@ import { get } from 'lodash';
import {
INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface';
import {
NodeHelpers,
@ -208,7 +236,12 @@ import {
} from 'n8n-workflow';
import CodeEdit from '@/components/CodeEdit.vue';
import CredentialsSelect from '@/components/CredentialsSelect.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import ScopesNotice from '@/components/ScopesNotice.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
// @ts-ignore
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
@ -218,6 +251,8 @@ import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
export default mixins(
externalHooks,
@ -230,7 +265,12 @@ export default mixins(
components: {
CodeEdit,
ExpressionEdit,
NodeCredentials,
CredentialsSelect,
PrismEditor,
ScopesNotice,
ParameterOptions,
ParameterIssues,
TextEdit,
},
props: [
@ -257,6 +297,8 @@ export default mixins(
remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false,
tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one
CUSTOM_API_CALL_KEY,
activeCredentialType: '',
dateTimePickerOptions: {
shortcuts: [
{
@ -303,6 +345,7 @@ export default mixins(
},
},
computed: {
...mapGetters('credentials', ['allCredentialTypes']),
areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled'];
},
@ -373,6 +416,13 @@ export default mixins(
returnValue = this.expressionValueComputed;
}
if (this.parameter.type === 'credentialsSelect') {
const credType = this.$store.getters['credentials/getCredentialTypeByName'](this.value);
if (credType) {
returnValue = credType.displayName;
}
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && returnValue.charAt(0) === '#') {
// Convert the value to rgba that el-color-picker can display it correctly
const bigint = parseInt(returnValue.slice(1), 16);
@ -471,7 +521,17 @@ export default mixins(
const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'), this.node);
if (['options', 'multiOptions'].includes(this.parameter.type) && this.remoteParameterOptionsLoading === false && this.remoteParameterOptionsLoadingIssues === null) {
if (this.parameter.type === 'credentialsSelect' && this.displayValue === '') {
issues.parameters = issues.parameters || {};
const issue = this.$locale.baseText('parameterInput.selectACredentialTypeFromTheDropdown');
issues.parameters[this.parameter.name] = [issue];
} else if (
['options', 'multiOptions'].includes(this.parameter.type) &&
this.remoteParameterOptionsLoading === false &&
this.remoteParameterOptionsLoadingIssues === null
) {
// Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed
@ -479,18 +539,28 @@ export default mixins(
const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value);
const checkValues: string[] = [];
if (Array.isArray(this.displayValue)) {
checkValues.push.apply(checkValues, this.displayValue);
} else {
checkValues.push(this.displayValue as string);
if (!this.skipCheck(this.displayValue)) {
if (Array.isArray(this.displayValue)) {
checkValues.push.apply(checkValues, this.displayValue);
} else {
checkValues.push(this.displayValue as string);
}
}
for (const checkValue of checkValues) {
if (checkValue !== undefined && checkValue.includes(CUSTOM_API_CALL_KEY)) continue;
if (checkValue === null || !validOptions.includes(checkValue)) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
issues.parameters[this.parameter.name] = [`The value "${checkValue}" is not supported!`];
const issue = this.$locale.baseText(
'parameterInput.theValueIsNotSupported',
{ interpolate: { checkValue } },
);
issues.parameters[this.parameter.name] = [issue];
}
}
} else if (this.remoteParameterOptionsLoadingIssues !== null) {
@ -557,6 +627,9 @@ export default mixins(
const styles = {
width: '100%',
};
if (this.parameter.type === 'credentialsSelect') {
return styles;
}
if (this.displayOptionsComputed === true) {
deductWidth += 25;
}
@ -583,6 +656,23 @@ export default mixins(
},
},
methods: {
credentialSelected (updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.getNodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
},
/**
* Check whether a param value must be skipped when collecting node param issues for validation.
*/
skipCheck(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
},
getPlaceholder(): string {
return this.isForCredential
? this.$locale.credText().placeholder(this.parameter)
@ -737,6 +827,10 @@ export default mixins(
this.$emit('textInput', parameterData);
},
valueChanged (value: string[] | string | number | boolean | Date | null) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
if (value instanceof Date) {
value = value.toISOString();
}
@ -790,6 +884,10 @@ export default mixins(
this.nodeName = this.node.name;
}
if (this.node && this.node.parameters.authentication === 'predefinedCredentialType') {
this.activeCredentialType = this.node.parameters.nodeCredentialType as string;
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && this.displayValue !== null && this.displayValue.toString().charAt(0) !== '#') {
const newValue = this.rgbaToHex(this.displayValue as string);
if (newValue !== null) {
@ -856,20 +954,6 @@ export default mixins(
display: inline-block;
}
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
}
::v-deep .color-input {
display: flex;

View file

@ -1,6 +1,8 @@
<template>
<div class="paramter-input-list-wrapper">
<div v-for="parameter in filteredParameters" :key="parameter.name" :class="{indent}">
<div class="parameter-input-list-wrapper">
<div v-for="(parameter, index) in filteredParameters" :key="parameter.name">
<slot v-if="indexToShowSlotAt === index" />
<div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item"
@ -18,8 +20,6 @@
v-else-if="parameter.type === 'notice'"
class="parameter-item"
:content="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:truncate="parameter.typeOptions && parameter.typeOptions.truncate"
:truncate-at="parameter.typeOptions && parameter.typeOptions.truncateAt"
/>
<div
@ -101,6 +101,7 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get, set } from 'lodash';
import mixins from 'vue-typed-mixins';
import { WEBHOOK_NODE_TYPE } from '@/constants';
export default mixins(
genericHelpers,
@ -129,6 +130,11 @@ export default mixins(
node (): INodeUi {
return this.$store.getters.activeNode;
},
indexToShowSlotAt (): number {
if (this.node.type === WEBHOOK_NODE_TYPE) return 1;
return 0;
},
},
methods: {
multipleValues (parameter: INodeProperties): boolean {
@ -164,11 +170,27 @@ export default mixins(
this.$emit('valueChanged', parameterData);
},
mustHideDuringCustomApiCall (parameter: INodeProperties, nodeValues: INodeParameters): boolean {
if (parameter && parameter.displayOptions && parameter.displayOptions.hide) return true;
const MUST_REMAIN_VISIBLE = ['authentication', 'resource', 'operation', ...Object.keys(nodeValues)];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
},
displayNodeParameter (parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (
this.isCustomApiCallSelected(this.nodeValues) &&
this.mustHideDuringCustomApiCall(parameter, this.nodeValues)
) {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
@ -260,7 +282,7 @@ export default mixins(
</script>
<style lang="scss">
.paramter-input-list-wrapper {
.parameter-input-list-wrapper {
.delete-option {
display: none;
position: absolute;

View file

@ -0,0 +1,30 @@
<template>
<div :class="$style['parameter-issues']" v-if="issues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + issues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterIssues',
props: [
'issues',
],
});
</script>
<style module lang="scss">
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
padding-left: var(--spacing-4xs);
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<div :class="$style['parameter-options']">
<el-dropdown
trigger="click"
@command="(opt) => $emit('optionSelected', opt)"
size="mini"
>
<span class="el-dropdown-link">
<font-awesome-icon
icon="cogs"
class="reset-icon clickable"
:title="$locale.baseText('parameterInput.parameterOptions')"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="parameter.noDataExpression !== true && !isValueExpression"
command="addExpression"
>
{{ $locale.baseText('parameterInput.addExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="parameter.noDataExpression !== true && isValueExpression"
command="removeExpression"
>
{{ $locale.baseText('parameterInput.removeExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="hasRemoteMethod"
command="refreshOptions"
>
{{ $locale.baseText('parameterInput.refreshList') }}
</el-dropdown-item>
<el-dropdown-item
command="resetValue"
:disabled="isDefault"
divided
>
{{ $locale.baseText('parameterInput.resetValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterOptions',
props: [
'displayOptionsComputed',
'optionSelected',
'parameter',
'isValueExpression',
'isDefault',
'hasRemoteMethod',
],
});
</script>
<style module lang="scss">
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<n8n-notice
:content="scopesShortContent"
:fullContent="scopesFullContent"
/>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'ScopesNotice',
props: [
'activeCredentialType',
'scopes',
],
computed: {
...mapGetters('credentials', ['getCredentialTypeByName']),
scopesShortContent (): string {
return this.$locale.baseText(
'nodeSettings.scopes.notice',
{
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
},
},
);
},
scopesFullContent (): string {
return this.$locale.baseText(
'nodeSettings.scopes.expandedNoticeWithScopes',
{
adjustToNumber: this.scopes.length,
interpolate: {
activeCredential: this.shortCredentialDisplayName,
scopes: this.scopes.map(
(s: string) => s.includes('/') ? s.split('/').pop() : s,
).join('<br>'),
},
},
);
},
shortCredentialDisplayName (): string {
const oauth1Api = this.$locale.baseText('generic.oauth1Api');
const oauth2Api = this.$locale.baseText('generic.oauth2Api');
return this.getCredentialTypeByName(this.activeCredentialType).displayName
.replace(new RegExp(`${oauth1Api}|${oauth2Api}`), '')
.trim();
},
},
});
</script>

View file

@ -1,5 +1,6 @@
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
CUSTOM_API_CALL_KEY,
} from '@/constants';
import {
@ -32,12 +33,30 @@ import { restApi } from '@/components/mixins/restApi';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
export const nodeHelpers = mixins(
restApi,
)
.extend({
computed: {
...mapGetters('credentials', [ 'getCredentialTypeByName', 'getCredentialsByType' ]),
},
methods: {
hasProxyAuth (node: INodeUi): boolean {
return Object.keys(node.parameters).includes('nodeCredentialType');
},
isCustomApiCallSelected (nodeValues: INodeParameters): boolean {
const { parameters } = nodeValues;
if (!isObjectLiteral(parameters)) return false;
return (
parameters.resource !== undefined && parameters.resource.includes(CUSTOM_API_CALL_KEY) ||
parameters.operation !== undefined && parameters.operation.includes(CUSTOM_API_CALL_KEY)
);
},
// Returns the parameter value
getParameterValue (nodeValues: INodeParameters, parameterName: string, path: string) {
@ -116,6 +135,23 @@ export const nodeHelpers = mixins(
return false;
},
reportUnsetCredential(credentialType: ICredentialType) {
return {
credentials: {
[credentialType.name]: [
this.$locale.baseText(
'nodeHelpers.credentialsUnset',
{
interpolate: {
credentialType: credentialType.displayName,
},
},
),
],
},
};
},
// Updates the execution issues.
updateNodesExecutionIssues () {
const nodes = this.$store.getters.allNodes;
@ -198,6 +234,46 @@ export const nodeHelpers = mixins(
let credentialType: ICredentialType | null;
let credentialDisplayName: string;
let selectedCredentials: INodeCredentialsDetails;
const {
authentication,
genericAuthType,
nodeCredentialType,
} = node.parameters as HttpRequestNode.V2.AuthParams;
if (
authentication === 'genericCredentialType' &&
genericAuthType !== '' &&
selectedCredsAreUnusable(node, genericAuthType)
) {
const credential = this.getCredentialTypeByName(genericAuthType);
return this.reportUnsetCredential(credential);
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
node.credentials !== undefined
) {
const stored = this.getCredentialsByType(nodeCredentialType);
if (selectedCredsDoNotExist(node, nodeCredentialType, stored)) {
const credential = this.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
}
if (
this.hasProxyAuth(node) &&
authentication === 'predefinedCredentialType' &&
nodeCredentialType !== '' &&
selectedCredsAreUnusable(node, nodeCredentialType)
) {
const credential = this.getCredentialTypeByName(nodeCredentialType);
return this.reportUnsetCredential(credential);
}
for (const credentialTypeDescription of nodeType!.credentials!) {
// Check if credentials should be displayed else ignore
if (this.displayParameter(node.parameters, credentialTypeDescription, '', node) !== true) {
@ -398,3 +474,43 @@ export const nodeHelpers = mixins(
},
},
});
/**
* Whether the node has no selected credentials, or none of the node's
* selected credentials are of the specified type.
*/
function selectedCredsAreUnusable(node: INodeUi, credentialType: string) {
return node.credentials === undefined || Object.keys(node.credentials).includes(credentialType) === false;
}
/**
* Whether the node's selected credentials of the specified type
* can no longer be found in the database.
*/
function selectedCredsDoNotExist(
node: INodeUi,
nodeCredentialType: string,
storedCredsByType: ICredentialsResponse[] | null,
) {
if (!node.credentials || !storedCredsByType) return false;
const selectedCredsByType = node.credentials[nodeCredentialType];
if (!selectedCredsByType) return false;
return !storedCredsByType.find((c) => c.id === selectedCredsByType.id);
}
declare namespace HttpRequestNode {
namespace V2 {
type AuthParams = {
authentication: 'none' | 'genericCredentialType' | 'predefinedCredentialType';
genericAuthType: string;
nodeCredentialType: string;
};
}
}
function isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } {
return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject);
}

View file

@ -27,8 +27,13 @@ export const showMessage = mixins(externalHooks).extend({
stickyNotificationQueue.push(notification);
}
if(messageData.type === 'error' && track) {
this.$telemetry.track('Instance FE emitted error', { error_title: messageData.title, error_message: messageData.message, workflow_id: this.$store.getters.workflowId });
if (messageData.type === 'error' && track) {
this.$telemetry.track('Instance FE emitted error', {
error_title: messageData.title,
error_message: messageData.message,
caused_by_credential: this.causedByCredential(messageData.message),
workflow_id: this.$store.getters.workflowId,
});
}
return notification;
@ -135,7 +140,14 @@ export const showMessage = mixins(externalHooks).extend({
message,
errorMessage: error.message,
});
this.$telemetry.track('Instance FE emitted error', { error_title: title, error_description: message, error_message: error.message, workflow_id: this.$store.getters.workflowId });
this.$telemetry.track('Instance FE emitted error', {
error_title: title,
error_description: message,
error_message: error.message,
caused_by_credential: this.causedByCredential(error.message),
workflow_id: this.$store.getters.workflowId,
});
},
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise<boolean> {
@ -203,5 +215,14 @@ export const showMessage = mixins(externalHooks).extend({
</details>
`;
},
/**
* Whether a workflow execution error was caused by a credential issue, as reflected by the error message.
*/
causedByCredential(message: string | undefined) {
if (!message) return false;
return message.includes('Credentials for') && message.includes('are not set');
},
},
});

View file

@ -330,6 +330,11 @@ export const workflowHelpers = mixins(
if (node.credentials !== undefined && nodeType.credentials !== undefined) {
const saveCredenetials: INodeCredentials = {};
for (const nodeCredentialTypeName of Object.keys(node.credentials)) {
if (this.hasProxyAuth(node) || Object.keys(node.parameters).includes('genericAuthType')) {
saveCredenetials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
continue;
}
const credentialTypeDescription = nodeType.credentials
.find((credentialTypeDescription) => credentialTypeDescription.name === nodeCredentialTypeName);

View file

@ -4,6 +4,9 @@ export const NODE_NAME_PREFIX = 'node-';
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
// parameter input
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
// workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const DEFAULT_NODETYPE_VERSION = 1;

View file

@ -23,6 +23,7 @@ import {
ICredentialsDecrypted,
INodeCredentialTestResult,
INodeTypeDescription,
INodeProperties,
} from 'n8n-workflow';
import { getAppNameFromCredType } from '@/components/helpers';
@ -120,6 +121,35 @@ const module: Module<ICredentialsState, IRootState> = {
});
};
},
getScopesByCredentialType (_: ICredentialsState, getters: any) { // tslint:disable-line:no-any
return (credentialTypeName: string) => {
const credentialType = getters.getCredentialTypeByName(credentialTypeName) as {
properties: INodeProperties[];
};
const scopeProperty = credentialType.properties.find((p) => p.name === 'scope');
if (
!scopeProperty ||
!scopeProperty.default ||
typeof scopeProperty.default !== 'string' ||
scopeProperty.default === ''
) {
return [];
}
let { default: scopeDefault } = scopeProperty;
// disregard expressions for display
scopeDefault = scopeDefault.replace(/^=/, '').replace(/\{\{.*\}\}/, '');
if (/ /.test(scopeDefault)) return scopeDefault.split(' ');
if (/,/.test(scopeDefault)) return scopeDefault.split(',');
return [scopeDefault];
};
},
},
actions: {
fetchCredentialTypes: async (context: ActionContext<ICredentialsState, IRootState>) => {

View file

@ -318,8 +318,8 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
{
"nodeView.resource.displayName": "🇩🇪 Resource",
"nodeView.resource.description": "🇩🇪 Resource to operate on",
"nodeView.resource.options.file.displayName": "🇩🇪 File",
"nodeView.resource.options.issue.displayName": "🇩🇪 Issue",
"nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.name": "🇩🇪 Issue",
}
```
@ -327,6 +327,16 @@ Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.de
<img src="img/node2.png" width="400">
</p>
For nodes whose credentials may be used in the HTTP Request node, an additional option `Custom API Call` is injected into the `Resource` and `Operation` parameters. Use the `__CUSTOM_API_CALL__` key to translate this additional option.
```json
{
"nodeView.resource.options.file.name": "🇩🇪 File",
"nodeView.resource.options.issue.name": "🇩🇪 Issue",
"nodeView.resource.options.__CUSTOM_API_CALL__.name": "🇩🇪 Custom API Call",
}
```
#### `collection` and `fixedCollection` parameters
Allowed keys: `displayName`, `description`, `placeholder`, `multipleValueButtonText`

View file

@ -242,6 +242,8 @@
"forgotPassword.returnToSignIn": "Back to sign in",
"forgotPassword.sendingEmailError": "Problem sending email",
"forgotPassword.smtpErrorContactAdministrator": "Please contact your administrator (problem with your SMTP setup)",
"generic.oauth1Api": "OAuth1 API",
"generic.oauth2Api": "OAuth2 API",
"genericHelpers.loading": "Loading",
"genericHelpers.min": "min",
"genericHelpers.sec": "sec",
@ -402,6 +404,7 @@
"nodeErrorView.stack": "Stack",
"nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed": "The error cause is too large to be displayed",
"nodeErrorView.time": "Time",
"nodeHelpers.credentialsUnset": "Credentials for '{credentialType}' are not set.",
"nodeSettings.alwaysOutputData.description": "If active, will output a single, empty item when the output would have been empty. Use to prevent the workflow finishing on this node.",
"nodeSettings.alwaysOutputData.displayName": "Always Output Data",
"nodeSettings.clickOnTheQuestionMarkIcon": "Click the '?' icon to open this node on n8n.io",
@ -421,8 +424,11 @@
"nodeSettings.parameters": "Parameters",
"nodeSettings.retryOnFail.description": "If active, the node tries to execute again when it fails",
"nodeSettings.retryOnFail.displayName": "Retry On Fail",
"nodeSettings.scopes.expandedNoticeWithScopes": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a> | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials<br>{scopes}<br><a data-key=\"show-less\">Show less</a>",
"nodeSettings.scopes.notice": "<a data-key=\"toggle-expand\">{count} scope</a> available for {activeCredential} credentials | <a data-key=\"toggle-expand\">{count} scopes</a> available for {activeCredential} credentials",
"nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown": "The node is not valid as its type ({nodeType}) is unknown",
"nodeSettings.thisNodeDoesNotHaveAnyParameters": "This node does not have any parameters",
"nodeSettings.useTheHttpRequestNode": "Use the <b>HTTP Request</b> node to make a custom API call. We'll take care of the {nodeTypeDisplayName} auth for you. <a target=\"_blank\" href=\"https://docs.n8n.io/integrations/custom-operations/\">Learn more</a>",
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"nodeView.addNode": "Add node",
@ -499,6 +505,7 @@
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.addExpression": "Add Expression",
"parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR",
"parameterInput.issues": "Issues",
"parameterInput.loadingOptions": "Loading options...",
@ -513,6 +520,8 @@
"parameterInput.resetValue": "Reset Value",
"parameterInput.select": "Select",
"parameterInput.selectDateAndTime": "Select date and time",
"parameterInput.selectACredentialTypeFromTheDropdown": "Select a credential type from the dropdown",
"parameterInput.theValueIsNotSupported": "The value \"{checkValue}\" is not supported!",
"parameterInputExpanded.openDocs": "Open docs",
"parameterInputExpanded.thisFieldIsRequired": "This field is required",
"parameterInputList.delete": "Delete",

View file

@ -5,7 +5,7 @@ import {
export class GithubApi implements ICredentialType {
name = 'githubApi';
displayName = 'Github API';
displayName = 'GitHub API';
documentationUrl = 'github';
properties: INodeProperties[] = [
{

View file

@ -9,7 +9,7 @@ export class GithubOAuth2Api implements ICredentialType {
extends = [
'oAuth2Api',
];
displayName = 'Github OAuth2 API';
displayName = 'GitHub OAuth2 API';
documentationUrl = 'github';
properties: INodeProperties[] = [
{

View file

@ -6,7 +6,7 @@ import {
export class GitlabApi implements ICredentialType {
name = 'gitlabApi';
displayName = 'Gitlab API';
displayName = 'GitLab API';
documentationUrl = 'gitlab';
properties: INodeProperties[] = [
{

View file

@ -9,7 +9,7 @@ export class GitlabOAuth2Api implements ICredentialType {
extends = [
'oAuth2Api',
];
displayName = 'Gitlab OAuth2 API';
displayName = 'GitLab OAuth2 API';
documentationUrl = 'gitlab';
properties: INodeProperties[] = [
{

View file

@ -8,6 +8,7 @@ export class HttpBasicAuth implements ICredentialType {
name = 'httpBasicAuth';
displayName = 'Basic Auth';
documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{

View file

@ -8,6 +8,7 @@ export class HttpDigestAuth implements ICredentialType {
name = 'httpDigestAuth';
displayName = 'Digest Auth';
documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{

View file

@ -9,6 +9,7 @@ export class HttpHeaderAuth implements ICredentialType {
name = 'httpHeaderAuth';
displayName = 'Header Auth';
documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{

View file

@ -8,6 +8,7 @@ export class HttpQueryAuth implements ICredentialType {
name = 'httpQueryAuth';
displayName = 'Query Auth';
documentationUrl = 'httpRequest';
genericAuth = true;
icon = 'node:n8n-nodes-base.httpRequest';
properties: INodeProperties[] = [
{

View file

@ -5,7 +5,7 @@ import {
export class HubspotApi implements ICredentialType {
name = 'hubspotApi';
displayName = 'Hubspot API';
displayName = 'HubSpot API';
documentationUrl = 'hubspot';
properties: INodeProperties[] = [
{

View file

@ -5,7 +5,7 @@ import {
export class HubspotAppToken implements ICredentialType {
name = 'hubspotAppToken';
displayName = 'Hubspot App Token';
displayName = 'HubSpot App Token';
documentationUrl = 'hubspot';
properties: INodeProperties[] = [
{

View file

@ -14,7 +14,7 @@ const scopes = [
export class HubspotDeveloperApi implements ICredentialType {
name = 'hubspotDeveloperApi';
displayName = 'Hubspot Developer API';
displayName = 'HubSpot Developer API';
documentationUrl = 'hubspot';
extends = [
'oAuth2Api',

View file

@ -23,7 +23,7 @@ export class HubspotOAuth2Api implements ICredentialType {
extends = [
'oAuth2Api',
];
displayName = 'Hubspot OAuth2 API';
displayName = 'HubSpot OAuth2 API';
documentationUrl = 'hubspot';
properties: INodeProperties[] = [
{

View file

@ -7,6 +7,7 @@ export class OAuth1Api implements ICredentialType {
name = 'oAuth1Api';
displayName = 'OAuth1 API';
documentationUrl = 'httpRequest';
genericAuth = true;
properties: INodeProperties[] = [
{
displayName: 'Authorization URL',

View file

@ -8,6 +8,7 @@ export class OAuth2Api implements ICredentialType {
name = 'oAuth2Api';
displayName = 'OAuth2 API';
documentationUrl = 'httpRequest';
genericAuth = true;
properties: INodeProperties[] = [
{
displayName: 'Authorization URL',

View file

@ -1,10 +1,14 @@
import path from 'path';
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IAuthenticate,
IBinaryData,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
NodeApiError,
@ -29,7 +33,7 @@ export class HttpRequest implements INodeType {
name: 'httpRequest',
icon: 'fa:at',
group: ['input'],
version: 1,
version: [1, 2],
subtitle: '={{$parameter["requestMethod"] + ": " + $parameter["url"]}}',
description: 'Makes an HTTP request and returns the response data',
defaults: {
@ -39,6 +43,97 @@ export class HttpRequest implements INodeType {
inputs: ['main'],
outputs: ['main'],
credentials: [
// ----------------------------------
// v2 creds
// ----------------------------------
{
name: 'httpBasicAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpBasicAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpDigestAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpDigestAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpHeaderAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpHeaderAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'httpQueryAuth',
required: true,
displayOptions: {
show: {
authentication: [
'httpQueryAuth',
],
'@version': [
2,
],
},
},
},
{
name: 'oAuth1Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth1Api',
],
'@version': [
2,
],
},
},
},
{
name: 'oAuth2Api',
required: true,
displayOptions: {
show: {
authentication: [
'oAuth2Api',
],
'@version': [
2,
],
},
},
},
// ----------------------------------
// v1 creds
// ----------------------------------
{
name: 'httpBasicAuth',
required: true,
@ -47,6 +142,9 @@ export class HttpRequest implements INodeType {
authentication: [
'basicAuth',
],
'@version': [
1,
],
},
},
},
@ -58,6 +156,9 @@ export class HttpRequest implements INodeType {
authentication: [
'digestAuth',
],
'@version': [
1,
],
},
},
},
@ -69,6 +170,9 @@ export class HttpRequest implements INodeType {
authentication: [
'headerAuth',
],
'@version': [
1,
],
},
},
},
@ -80,6 +184,9 @@ export class HttpRequest implements INodeType {
authentication: [
'queryAuth',
],
'@version': [
1,
],
},
},
},
@ -91,6 +198,9 @@ export class HttpRequest implements INodeType {
authentication: [
'oAuth1',
],
'@version': [
1,
],
},
},
},
@ -107,6 +217,87 @@ export class HttpRequest implements INodeType {
},
],
properties: [
// ----------------------------------
// v2 params
// ----------------------------------
{
displayName: 'Authentication',
name: 'authentication',
noDataExpression: true,
type: 'options',
required: true,
options: [
{
name: 'None',
value: 'none',
},
{
name: 'Predefined Credential Type',
value: 'predefinedCredentialType',
description: 'We\'ve already implemented auth for many services so that you don\'t have to set it up manually',
},
{
name: 'Generic Credential Type',
value: 'genericCredentialType',
description: 'Fully customizable. Choose between basic, header, OAuth2, etc.',
},
],
default: 'none',
displayOptions: {
show: {
'@version': [
2,
],
},
},
},
{
displayName: 'Credential Type',
name: 'nodeCredentialType',
type: 'credentialsSelect',
noDataExpression: true,
required: true,
default: '',
credentialTypes: [
'extends:oAuth2Api',
'extends:oAuth1Api',
'has:authenticate',
],
displayOptions: {
show: {
authentication: [
'predefinedCredentialType',
],
'@version': [
2,
],
},
},
},
{
displayName: 'Generic Auth Type',
name: 'genericAuthType',
type: 'credentialsSelect',
required: true,
default: '',
credentialTypes: [
'has:genericAuth',
],
displayOptions: {
show: {
authentication: [
'genericCredentialType',
],
'@version': [
2,
],
},
},
},
// ----------------------------------
// v1 params
// ----------------------------------
{
displayName: 'Authentication',
name: 'authentication',
@ -143,7 +334,18 @@ export class HttpRequest implements INodeType {
],
default: 'none',
description: 'The way to authenticate',
displayOptions: {
show: {
'@version': [
1,
],
},
},
},
// ----------------------------------
// versionless params
// ----------------------------------
{
displayName: 'Request Method',
name: 'requestMethod',
@ -642,7 +844,6 @@ export class HttpRequest implements INodeType {
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
@ -653,47 +854,46 @@ export class HttpRequest implements INodeType {
'statusMessage',
];
// TODO: Should have a setting which makes clear that this parameter can not change for each item
const requestMethod = this.getNodeParameter('requestMethod', 0) as string;
const parametersAreJson = this.getNodeParameter('jsonParameters', 0) as boolean;
let authentication;
const nodeVersion = this.getNode().typeVersion;
const responseFormat = this.getNodeParameter('responseFormat', 0) as string;
try {
authentication = this.getNodeParameter('authentication', 0) as 'predefinedCredentialType' | 'genericCredentialType' | 'none';
} catch (_) {}
let httpBasicAuth;
let httpDigestAuth;
let httpHeaderAuth;
let httpQueryAuth;
let oAuth1Api;
let oAuth2Api;
let nodeCredentialType;
try {
httpBasicAuth = await this.getCredentials('httpBasicAuth');
} catch (error) {
// Do nothing
}
try {
httpDigestAuth = await this.getCredentials('httpDigestAuth');
} catch (error) {
// Do nothing
}
try {
httpHeaderAuth = await this.getCredentials('httpHeaderAuth');
} catch (error) {
// Do nothing
}
try {
httpQueryAuth = await this.getCredentials('httpQueryAuth');
} catch (error) {
// Do nothing
}
try {
oAuth1Api = await this.getCredentials('oAuth1Api');
} catch (error) {
// Do nothing
}
try {
oAuth2Api = await this.getCredentials('oAuth2Api');
} catch (error) {
// Do nothing
if (authentication === 'genericCredentialType' || nodeVersion === 1) {
try {
httpBasicAuth = await this.getCredentials('httpBasicAuth');
} catch (_) {}
try {
httpDigestAuth = await this.getCredentials('httpDigestAuth');
} catch (_) {}
try {
httpHeaderAuth = await this.getCredentials('httpHeaderAuth');
} catch (_) {}
try {
httpQueryAuth = await this.getCredentials('httpQueryAuth');
} catch (_) {}
try {
oAuth1Api = await this.getCredentials('oAuth1Api');
} catch (_) {}
try {
oAuth2Api = await this.getCredentials('oAuth2Api');
} catch (_) {}
} else if (authentication === 'predefinedCredentialType') {
try {
nodeCredentialType = this.getNodeParameter('nodeCredentialType', 0) as string;
} catch (_) {}
}
let requestOptions: OptionsWithUri;
@ -723,6 +923,9 @@ export class HttpRequest implements INodeType {
const returnItems: INodeExecutionData[] = [];
const requestPromises = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
const requestMethod = this.getNodeParameter('requestMethod', itemIndex) as string;
const parametersAreJson = this.getNodeParameter('jsonParameters', itemIndex) as boolean;
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
const url = this.getNodeParameter('url', itemIndex) as string;
@ -983,13 +1186,30 @@ export class HttpRequest implements INodeType {
this.sendMessageToUI(sendRequest);
} catch (e) { }
// Now that the options are all set make the actual http request
if (oAuth1Api !== undefined) {
requestPromises.push(this.helpers.requestOAuth1.call(this, 'oAuth1Api', requestOptions));
} else if (oAuth2Api !== undefined) {
requestPromises.push(this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, { tokenType: 'Bearer' }));
} else {
requestPromises.push(this.helpers.request(requestOptions));
if (
authentication === 'genericCredentialType' ||
authentication === 'none' ||
nodeVersion === 1
) {
if (oAuth1Api) {
requestPromises.push(
this.helpers.requestOAuth1.call(this, 'oAuth1Api', requestOptions),
);
} else if (oAuth2Api) {
requestPromises.push(
this.helpers.requestOAuth2.call(this, 'oAuth2Api', requestOptions, { tokenType: 'Bearer' }),
);
} else {
// bearerAuth, queryAuth, headerAuth, digestAuth, none
requestPromises.push(
this.helpers.request(requestOptions),
);
}
} else if (authentication === 'predefinedCredentialType' && nodeCredentialType) {
// service-specific cred: OAuth1, OAuth2, plain
requestPromises.push(
this.helpers.requestWithAuthentication.call(this, nodeCredentialType, requestOptions),
);
}
}

View file

@ -269,7 +269,6 @@
"dist/credentials/SplunkApi.credentials.js",
"dist/credentials/SpontitApi.credentials.js",
"dist/credentials/SpotifyOAuth2Api.credentials.js",
"dist/credentials/SpotifyOAuth2Api.credentials.js",
"dist/credentials/SshPassword.credentials.js",
"dist/credentials/SshPrivateKey.credentials.js",
"dist/credentials/StackbyApi.credentials.js",

View file

@ -276,6 +276,7 @@ export interface ICredentialType {
__overwrittenProperties?: string[];
authenticate?: IAuthenticate;
test?: ICredentialTestRequest;
genericAuth?: boolean;
}
export interface ICredentialTypes {
@ -831,7 +832,8 @@ export type NodePropertyTypes =
| 'multiOptions'
| 'number'
| 'options'
| 'string';
| 'string'
| 'credentialsSelect';
export type CodeAutocompleteTypes = 'function' | 'functionItem';
@ -861,8 +863,6 @@ export interface INodePropertyTypeOptions {
rows?: number; // Supported by: string
showAlpha?: boolean; // Supported by: color
sortable?: boolean; // Supported when "multipleValues" set to true
truncate?: boolean; // Supported by: notice
truncateAt?: number; // Supported by: notice
[key: string]: any;
}
@ -890,6 +890,9 @@ export interface INodeProperties {
noDataExpression?: boolean;
required?: boolean;
routing?: INodePropertyRouting;
credentialTypes?: Array<
'extends:oAuth2Api' | 'extends:oAuth1Api' | 'has:authenticate' | 'has:genericAuth'
>;
}
export interface INodePropertyOptions {
name: string;
@ -1434,9 +1437,14 @@ export interface INodeGraphItem {
type: string;
resource?: string;
operation?: string;
domain?: string;
domain?: string; // HTTP Request node v1
domain_base?: string; // HTTP Request node v2
domain_path?: string; // HTTP Request node v2
position: [number, number];
mode?: string;
credential_type?: string; // HTTP Request node v2
credential_set?: boolean; // HTTP Request node v2
method?: string; // HTTP Request node v2
}
export interface INodeNameIndex {

View file

@ -60,6 +60,59 @@ function areOverlapping(
);
}
const URL_PARTS_REGEX = /(?<protocolPlusDomain>.*?\..*?)(?<pathname>\/.*)/;
export function getDomainBase(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
return [url.protocol, url.hostname].join('//');
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.protocolPlusDomain) return '';
return match.groups.protocolPlusDomain;
}
}
function isSensitive(segment: string) {
if (/^v\d+$/.test(segment)) return false;
return /%40/.test(segment) || /\d/.test(segment) || /^[0-9A-F]{8}/i.test(segment);
}
export const ANONYMIZATION_CHARACTER = '*';
function sanitizeRoute(raw: string, check = isSensitive, char = ANONYMIZATION_CHARACTER) {
return raw
.split('/')
.map((segment) => (check(segment) ? char.repeat(segment.length) : segment))
.join('/');
}
/**
* Return pathname plus query string from URL, anonymizing IDs in route and query params.
*/
export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string {
try {
const url = new URL(raw);
if (!url.hostname) throw new Error('Malformed URL');
return sanitizeRoute(url.pathname);
} catch (_) {
const match = urlParts.exec(raw);
if (!match?.groups?.pathname) return '';
// discard query string
const route = match.groups.pathname.split('?').shift() as string;
return sanitizeRoute(route);
}
}
export function generateNodesGraph(
workflow: IWorkflowBase,
nodeTypes: INodeTypes,
@ -100,12 +153,30 @@ export function generateNodesGraph(
position: node.position,
};
if (node.type === 'n8n-nodes-base.httpRequest') {
if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) {
try {
nodeItem.domain = new URL(node.parameters.url as string).hostname;
} catch (e) {
nodeItem.domain = node.parameters.url as string;
} catch (_) {
nodeItem.domain = getDomainBase(node.parameters.url as string);
}
} else if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 2) {
const { authentication } = node.parameters as { authentication: string };
nodeItem.credential_type = {
none: 'none',
genericCredentialType: node.parameters.genericAuthType as string,
existingCredentialType: node.parameters.nodeCredentialType as string,
}[authentication];
nodeItem.credential_set = node.credentials
? Object.keys(node.credentials).length > 0
: false;
const { url } = node.parameters as { url: string };
nodeItem.domain_base = getDomainBase(url);
nodeItem.domain_path = getDomainPath(url);
nodeItem.method = node.parameters.requestMethod as string;
} else {
const nodeType = nodeTypes.getByNameAndVersion(node.type);

View file

@ -0,0 +1,191 @@
import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import {
ANONYMIZATION_CHARACTER as CHAR,
getDomainBase,
getDomainPath,
} from '../src/TelemetryHelpers';
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
for (const url of validUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(numericId)) {
const { full, protocolPlusDomain } = url;
expect(getDomainBase(full)).toBe(protocolPlusDomain);
}
});
});
describe('getDomainPath should return pathname, excluding query string', () => {
describe('anonymizing strings containing at least one number', () => {
test('in valid URLs', () => {
for (const url of validUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(alphanumericId)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing UUIDs', () => {
test('in valid URLs', () => {
for (const url of uuidUrls(validUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of uuidUrls(malformedUrls)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
describe('anonymizing emails', () => {
test('in valid URLs', () => {
for (const url of validUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
test('in malformed URLs', () => {
for (const url of malformedUrls(email)) {
const { full, pathname } = url;
expect(getDomainPath(full)).toBe(pathname);
}
});
});
});
function validUrls(idMaker: typeof alphanumericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `https://test.com/api/v1/users/${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `https://test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: '/api/v1/users',
},
{
full: `https://test.com/api/v1/users/${firstId}/posts/${secondId}`,
protocolPlusDomain: 'https://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}`,
},
];
}
function malformedUrls(idMaker: typeof numericId | typeof email, char = CHAR) {
const firstId = idMaker();
const secondId = idMaker();
const firstIdObscured = char.repeat(firstId.length);
const secondIdObscured = char.repeat(secondId.length);
return [
{
full: `test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `htp://test.com/api/v1/users/${firstId}/posts/${secondId}/`,
protocolPlusDomain: 'htp://test.com',
pathname: `/api/v1/users/${firstIdObscured}/posts/${secondIdObscured}/`,
},
{
full: `test.com/api/v1/users?id=${firstId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
{
full: `test.com/api/v1/users?id=${firstId}&post=${secondId}`,
protocolPlusDomain: 'test.com',
pathname: '/api/v1/users',
},
];
}
const email = () => encodeURIComponent('test@test.com');
function uuidUrls(
urlsMaker: typeof validUrls | typeof malformedUrls,
baseName = 'test',
namespaceUuid = uuidv4(),
) {
return [
...urlsMaker(() => uuidv5(baseName, namespaceUuid)),
...urlsMaker(uuidv4),
...urlsMaker(() => uuidv3(baseName, namespaceUuid)),
...urlsMaker(uuidv1),
];
}
function digit() {
return Math.floor(Math.random() * 10);
}
function positiveDigit(): number {
const d = digit();
return d === 0 ? positiveDigit() : d;
}
function numericId(length = positiveDigit()) {
return Array.from({ length }, digit).join('');
}
function alphanumericId() {
return chooseRandomly([
`john${numericId()}`,
`title${numericId(1)}`,
numericId(),
]);
}
const chooseRandomly = <T>(array: T[]) => array[Math.floor(Math.random() * array.length)];