mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34:06 -08:00
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 commit75eea89273
. * ⚡ 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` * ⏪ Revert3f2c4a6
* ⚡ 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:
parent
0212d65dae
commit
336fc9e2a8
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
4
packages/cli/src/requests.d.ts
vendored
4
packages/cli/src/requests.d.ts
vendored
|
@ -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 }>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>"
|
||||
`;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 >;
|
||||
|
|
153
packages/editor-ui/src/components/CredentialsSelect.vue
Normal file
153
packages/editor-ui/src/components/CredentialsSelect.vue
Normal 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>
|
|
@ -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]);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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 /> - ` + getIssues.join('<br /> - ')"></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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
30
packages/editor-ui/src/components/ParameterIssues.vue
Normal file
30
packages/editor-ui/src/components/ParameterIssues.vue
Normal 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 /> - ` + issues.join('<br /> - ')"></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>
|
68
packages/editor-ui/src/components/ParameterOptions.vue
Normal file
68
packages/editor-ui/src/components/ParameterOptions.vue
Normal 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>
|
55
packages/editor-ui/src/components/ScopesNotice.vue
Normal file
55
packages/editor-ui/src/components/ScopesNotice.vue
Normal 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>
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
|
||||
export class GithubApi implements ICredentialType {
|
||||
name = 'githubApi';
|
||||
displayName = 'Github API';
|
||||
displayName = 'GitHub API';
|
||||
documentationUrl = 'github';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -9,7 +9,7 @@ export class GithubOAuth2Api implements ICredentialType {
|
|||
extends = [
|
||||
'oAuth2Api',
|
||||
];
|
||||
displayName = 'Github OAuth2 API';
|
||||
displayName = 'GitHub OAuth2 API';
|
||||
documentationUrl = 'github';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
|
||||
export class GitlabApi implements ICredentialType {
|
||||
name = 'gitlabApi';
|
||||
displayName = 'Gitlab API';
|
||||
displayName = 'GitLab API';
|
||||
documentationUrl = 'gitlab';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -9,7 +9,7 @@ export class GitlabOAuth2Api implements ICredentialType {
|
|||
extends = [
|
||||
'oAuth2Api',
|
||||
];
|
||||
displayName = 'Gitlab OAuth2 API';
|
||||
displayName = 'GitLab OAuth2 API';
|
||||
documentationUrl = 'gitlab';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
|
||||
export class HubspotApi implements ICredentialType {
|
||||
name = 'hubspotApi';
|
||||
displayName = 'Hubspot API';
|
||||
displayName = 'HubSpot API';
|
||||
documentationUrl = 'hubspot';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -5,7 +5,7 @@ import {
|
|||
|
||||
export class HubspotAppToken implements ICredentialType {
|
||||
name = 'hubspotAppToken';
|
||||
displayName = 'Hubspot App Token';
|
||||
displayName = 'HubSpot App Token';
|
||||
documentationUrl = 'hubspot';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -23,7 +23,7 @@ export class HubspotOAuth2Api implements ICredentialType {
|
|||
extends = [
|
||||
'oAuth2Api',
|
||||
];
|
||||
displayName = 'Hubspot OAuth2 API';
|
||||
displayName = 'HubSpot OAuth2 API';
|
||||
documentationUrl = 'hubspot';
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
|
|
|
@ -7,6 +7,7 @@ export class OAuth1Api implements ICredentialType {
|
|||
name = 'oAuth1Api';
|
||||
displayName = 'OAuth1 API';
|
||||
documentationUrl = 'httpRequest';
|
||||
genericAuth = true;
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
|
|
|
@ -8,6 +8,7 @@ export class OAuth2Api implements ICredentialType {
|
|||
name = 'oAuth2Api';
|
||||
displayName = 'OAuth2 API';
|
||||
documentationUrl = 'httpRequest';
|
||||
genericAuth = true;
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
191
packages/workflow/test/TelemetryHelpers.test.ts
Normal file
191
packages/workflow/test/TelemetryHelpers.test.ts
Normal 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)];
|
Loading…
Reference in a new issue