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

*  Create controller

*  Mount controller

* ✏️ Add error messages

*  Create scopes fetcher

*  Account for non-existent credential type

* 📘 Type scopes request

*  Adjust error message

* 🧪 Add tests

*  Introduce simple node versioning

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

* 🐛 Fix setting of parameters

* 🐛 Fix another instance where it sets the wrong parameter

*  Remove unnecessary TOODs

*  Re-version HTTP Request node

* 👕 Satisfy linter

*  Retrieve node version

*  Undo Jan's changes to Set node

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

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

* 🐛 Check for generic auth in node cred types

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

*  Discoverability flow (#3229)

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

*  Add computed property from versioning branch

* 🐛 Fix cred ref lost and unsaved

*  Make options consistent with cred type names

*  Use prop to set component order

*  Use constant and version

*  Fix rendering for generic auth creds

*  Mark as required on first selection

*  Implement discoverability flow

*  Mark as required on subsequent selections

*  Fix marking as required after cred deletion

*  Refactor to clean up

*  Detect position automatically

*  Add i18n to option label

*  Hide subtitle for custom action

*  Detect active credential type

*  Prop drilling to re-render select

* 🔥 Remove unneeded property

* ✏️ Rename arg

* 🔥 Remove unused import

* 🔥 Remove unneeded getters

* 🔥 Remove unused import

*  Generalize cred component positioning

*  Set up request

* 🐛 Fix edge case in endpoint

*  Display scopes alert box

*  Revert "Generalize cred comp positioning"

This reverts commit 75eea89273b854110fa6d1f96c7c1d78dd3b0731.

*  Consolidate HTTPRN check

*  Fix hue percentage to degree

* 🔥 Remove unused import

* 🔥 Remove unused import

* 🔥 Remove unused class

* 🔥 Remove unused import

* 📘 Create type for HTTPRN v2 auth params

* ✏️ Rename check

* 🔥 Remove unused import

* ✏️ Add i18n to `reportUnsetCredential()`

*  Refactor Alex's spacing changes

*  Post-merge fixes

*  Add docs link

* 🔥 Exclude Notion OAuth cred

* ✏️ Update copy

* ✏️ Rename param

* 🎨 Reposition notice and simplify styling

* ✏️ Update copy

* ✏️ Update copy

*  Hide params during custom action

*  Show notice if any cred type supported

* 🐛 Prevent scopes text overflow

* 🔥 Remove superfluous check

* ✏️ Break up docstring

* 🎨 Tweak notice styling

*  Reorder cred param in Webhook node

* ✏️ Shorten cred name in scopes notice

* 🧪 Update Notice snapshots

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

*  Revert 3f2c4a6

*  Apply feedback from Product

* 🧪 Update snapshot

*  Adjust regex expansion pattern for singular

* 🔥 Remove unused import

* 🔥 Remove logging

*  Make `somethingElse` key more unique

*  Move something else to constants

*  Consolidate notice component

*  Apply latest feedback

* 🧪 Update tests

* 🧪 Update snapshot

* ✏️ Fix singular version

* 🧪 Finalize tests

* ✏️ Rename constant

* 🧪 Expand tests

* 🔥 Remove `truncate` prop

* 🚚 Move scopes fetching to store

* 🚚 Move method to component

*  Use constant

*  Refactor `Notice` component

* 🧪 Update tests

* 🔥 Remove unused keys

*  Inject custom API call option

* 🔥 Remove unused props

* 🎨 Use `compact` prop

* 🧪 Update snapshots

* 🚚 Move scopes to store

* 🚚 Move `nodeCredentialTypes` to parent

* ✏️ Rename cred types per branding

* 🐛 Clear scopes when none

*  Add default

* 🚚 Move `newHttpRequestNodeCredentialType` to parent

* 🔥 Remove test data

*  Separate lines for readability

*  Change reference from node to node name

* ✏️ Rename i18n keys

*  Refactor OAuth check

* 🔥 Remove unused key

* 🚚 Move `OAuth1/2 API` to i18n

*  Refactor `skipCheck`

*  Add `stopPropagation` and `preventDefault`

* 🚚 Move active credential scopes logic to store

* 🎨 Fix spacing for `NodeWebhooks` component

*  Implement feedback

*  Update HTTPRN default and issue copy

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

*  Refactor into cred type param

*  Componentize scopes notice

* 🔥 Remove unused data

* 🔥 Remove unused `loadOptions`

*  Componentize `NodeCredentialType`

* 🐛 Fix param validation

* 🔥 Remove dup methods

*  Refactor all references to `isHttpRequestNodeV2`

* 🎨 Fix styling

* 🔥 Remove unused import

* 🔥 Remove unused properties

* 🎨 Fix spacing for Pipedrive Trigger node

* 🎨 Undo Webhook node styling change

* 🔥 Remove unused style

*  Cover `httpHeaderAuth` edge case

* 🐛 Fix `this.node` reference

* 🚚 Rename to `credentialsSelect`

* 🐛 Fix mistaken renaming

*  Set one attribute per line

*  Move condition to instantiation site

* 🚚 Rename prop

*  Refactor away `prepareScopesNotice`

* ✏️ Rename i18n keys

* ✏️ Update i18n calls

* ✏️ Add more i18n keys

* 🔥 Remove unused props

* ✏️ Add explanatory comment

*  Adjust check in `hasProxyAuth`

*  Refactor `credentialSelected` from prop to event

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

*  Eventify `optionSelected`

*  Add `noDataExpression`

* 🔥 Remove logging

* 🔥 Remove URL from scopes

*  Disregard expressions for display

* 🎨 Use CSS modules

* 📘 Tigthen interface

* 🐛 Fix generic auth display

* 🐛 Fix generic auth validation

* 📘 Loosen type

* 🚚 Move event params to end

*  Generalize reference

*  Refactor generic auth as `credentialsSelect` param

*  Restore check for `httpHeaderAuth `

* 🚚 Rename `existing` to `predefined`

* Extend metrics for HTTP Request node (#3282)

*  Extend metrics

* 🧪 Add tests

*  Update param names

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

*  Update check per new branch

*  Include generic auth check

*  Adjust telemetry (#3359)

*  Filter credential types by label

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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