feat(editor): Simplify NDV by moving authentication details to credentials modal (#5067)

*  Removing authentication parameter from NDV
*  Added auth type selector to credentials modal
* 🔨 Extracting reusable logic to util functions
*  Updating credentials position, adding label for radio buttons
*  Using first node credentials for nodes with single auth options and hiding auth selector UI in that case
*  Fixing credentials modal when opened from credentials page
*  Showing all available credentials in NDV credentials dropdown
*  Updating node credentials dropdown component to show credentials description. Disabling `Credentials of type not found` error in node
*  Moving auth related fields from NDV to credentials modal. Added support for multiple auth fileds
*  Moving NDV fields that authentication depends on to credentials modal
*  Keeping old auth/credentials UI in NDV for HTTP Request and Webhook nodes. Pre-populating credential type for HTTP request node when selected from 'app action' menu
* 💄 Use old label and field position for nodes that use old credentials UI in NDV
*  Implementing more generic way to find node's auth fileds
* 📚 Adding comments on parameter hiding logic
*  Fixing node auth options logic for multiple auth fields
* 👕 Fixing lint errors
* 💄 Addressing design review comments
*  Not selecting first auth option when opening new credential dialog
*  Using default credentials name and icon if authentication type is not selected
*  Updating credential data when auth type is changed
*  Setting new credentials type for HTTP Request and Webhook nodes
*  Setting nodes with access when changing auth type
* 👕 Fixing lint error
*  Updating active node auth type from credentials modal
*  Syncronizing credentials modal and dropdown
* 👕 Fixing linter error
*  Handling credential dropdown UI for multiple credentials
* 👕 Removing unused imports
*  Handling auth selection when default auth type is the first option
*  Updating credentials change listening logic
*  Resetting credential data when deleting a credential, disabling 'Details' and 'Sharing' tabs if auth type is not selected
* 🐛 Skipping credentials type check when showing mixed credentials in the dropdown and switching credentials type
*  Showing credential modal tabs for saved credentials
*  Preventing renaming credentials when no auth type is selected
* 🐛 Fixing credentials modal when opened from credentials page
*  Keeping auth radio buttons selected when switching tabs
*  Adding initial batch of credentials NDV tests
*  Updating node auth filed value when new credential type is selected
*  Using all available credential types for current node to sync credential dropdown with modal
*  Sorting mixed credentials by date, simplifying credential dropdown option logic
* 🔨 Extracting some reusable logic to utils
*  Improving required vs optional credentials detection and using it to show auth radio buttons
* 👕 Fixing lint errors
*  Adding more credentials tests
*  Filtering credential options based on authentication type
* 🔨 Refactoring credentials and auth utils
*  Updated handling of auth options in credentials modal to work with new logic
* 🔨 Getting the terminology in line
* 📚 Removing leftover comment
*  Updating node auth filed detection logic to account for different edge-cases
*  Adding Wait node as an exception for new UI
*  Updating NDV display when auth type changes
*  Updating default credentials name when auth type changes
*  Hiding auth settings after credentials are saved
*  Always showing credentials modal menu tabs
*  Improving main auth field detection logic so it doesn't account for authentication fields which can have `none` value
*  Restoring accidentally deleted not existing credential issue logic
*  Updating other nodes when deleted credentials have been updated
*  Using filtered auth type list to show or hide radio buttons section in credentials modal
* 👕 Addressing lint error
* 👌 Addressing PR review feedback
* 👕 Fixing lint issues
*  Updating main auth filed detection logic so it checks full dependency path to determine if the field is required or optional
* 👌 Addressing the rest of PR feedback
*  Updating credential tests
*  Resetting credential data on authentication type change
*  Created AuthTypeSelector component
* 👌 Addressing PR comments
*  Not resetting overwritten credential properties when changing auth type
*  Hiding auth selector section if there are no options to show
This commit is contained in:
Milorad FIlipović
2023-01-27 09:05:43 +01:00
committed by GitHub
parent 874c735d0a
commit b321c5e4ec
21 changed files with 1105 additions and 146 deletions

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { IUpdateInformation, NodeAuthenticationOption } from '@/Interface';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import {
getAuthTypeForNodeCredential,
getNodeAuthFields,
getNodeAuthOptions,
isAuthRelatedParameter,
} from '@/utils';
import { INodeProperties, INodeTypeDescription, NodeParameterValue } from 'n8n-workflow';
import { computed, onMounted, ref } from 'vue';
import Vue from 'vue';
export interface Props {
credentialType: Object;
}
const emit = defineEmits<{
(event: 'authTypeChanged', value: string): void;
}>();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const props = defineProps<Props>();
const selected = ref('');
const authRelatedFieldsValues = ref({} as { [key: string]: NodeParameterValue });
onMounted(() => {
if (activeNodeType.value?.credentials) {
const credentialsForType =
activeNodeType.value.credentials.find((cred) => cred.name === props.credentialType.name) ||
null;
const authOptionForCred = getAuthTypeForNodeCredential(
activeNodeType.value,
credentialsForType,
);
selected.value = authOptionForCred?.value || '';
}
// Populate default values of related fields
authRelatedFields.value.forEach((field) => {
Vue.set(authRelatedFieldsValues.value, field.name, field.default);
});
});
const activeNodeType = computed<INodeTypeDescription | null>(() => {
const activeNode = ndvStore.activeNode;
if (activeNode) {
return nodeTypesStore.getNodeType(activeNode.type, activeNode.typeVersion);
}
return null;
});
const authOptions = computed<NodeAuthenticationOption[]>(() => {
return getNodeAuthOptions(activeNodeType.value);
});
const filteredNodeAuthOptions = computed<NodeAuthenticationOption[]>(() => {
return authOptions.value.filter((option) => shouldShowAuthOption(option));
});
// These are node properties that authentication fields depend on
// (have them in their display options). They all are show here
// instead of in the NDV
const authRelatedFields = computed<INodeProperties[]>(() => {
const nodeAuthFields = getNodeAuthFields(activeNodeType.value);
return (
activeNodeType.value?.properties.filter((prop) =>
isAuthRelatedParameter(nodeAuthFields, prop),
) || []
);
});
function shouldShowAuthOption(option: NodeAuthenticationOption): boolean {
// Node auth radio button should be shown if any of the fields that it depends on
// has value specified in it's displayOptions.show
if (authRelatedFields.value.length === 0) {
// If there are no related fields, show option
return true;
}
let shouldDisplay = false;
Object.keys(authRelatedFieldsValues.value).forEach((fieldName) => {
if (option.displayOptions && option.displayOptions.show) {
if (
option.displayOptions.show[fieldName]?.includes(authRelatedFieldsValues.value[fieldName])
) {
shouldDisplay = true;
return;
}
}
});
return shouldDisplay;
}
function onAuthTypeChange(newType: string): void {
emit('authTypeChanged', newType);
}
function valueChanged(data: IUpdateInformation): void {
Vue.set(authRelatedFieldsValues.value, data.name, data.value);
}
defineExpose({
onAuthTypeChange,
});
</script>
<template>
<div v-if="filteredNodeAuthOptions.length > 0" data-test-id="node-auth-type-selector">
<div v-for="parameter in authRelatedFields" :key="parameter.name" class="mb-l">
<parameter-input-full
:parameter="parameter"
:value="authRelatedFieldsValues[parameter.name] || parameter.default"
:path="parameter.name"
:displayOptions="false"
@valueChanged="valueChanged"
/>
</div>
<div>
<n8n-input-label
:label="$locale.baseText('credentialEdit.credentialConfig.authTypeSelectorLabel')"
:tooltipText="$locale.baseText('credentialEdit.credentialConfig.authTypeSelectorTooltip')"
:required="true"
/>
</div>
<el-radio
v-for="prop in filteredNodeAuthOptions"
:key="prop.value"
v-model="selected"
:label="prop.value"
:class="$style.authRadioButton"
border
@change="onAuthTypeChange"
>{{ prop.name }}</el-radio
>
</div>
</template>
<style lang="scss" module>
.authRadioButton {
margin-right: 0 !important;
& + & {
margin-left: 8px !important;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div :class="$style.container">
<div :class="$style.container" data-test-id="node-credentials-config-container">
<banner
v-show="showValidationWarning"
theme="danger"
@@ -70,6 +70,12 @@
</span>
</n8n-notice>
<AuthTypeSelector
v-if="showAuthTypeSelector && isNewCredential"
:credentialType="credentialType"
@authTypeChanged="onAuthTypeChange"
/>
<CopyInput
v-if="isOAuthType && credentialProperties.length"
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
@@ -115,14 +121,14 @@
@click="$emit('oauth')"
/>
<n8n-text v-if="!credentialType" color="text-base" size="medium">
<n8n-text v-if="isMissingCredentials" color="text-base" size="medium">
{{ $locale.baseText('credentialEdit.credentialConfig.missingCredentialType') }}
</n8n-text>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { getAppNameFromCredType, isCommunityPackageName } from '@/utils';
import Banner from '../Banner.vue';
@@ -140,15 +146,21 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useRootStore } from '@/stores/n8nRootStore';
import { useNDVStore } from '@/stores/ndv';
import { useCredentialsStore } from '@/stores/credentials';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { ICredentialsResponse, IUpdateInformation, NodeAuthenticationOption } from '@/Interface';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
import GoogleAuthButton from './GoogleAuthButton.vue';
export default mixins(restApi).extend({
name: 'CredentialConfig',
components: {
AuthTypeSelector,
Banner,
CopyInput,
CredentialInputs,
OauthButton,
ParameterInputFull,
GoogleAuthButton,
},
props: {
@@ -192,6 +204,13 @@ export default mixins(restApi).extend({
requiredPropertiesFilled: {
type: Boolean,
},
mode: {
type: String,
required: true,
},
showAuthTypeSelector: {
type: Boolean,
},
},
data() {
return {
@@ -215,7 +234,22 @@ export default mixins(restApi).extend({
);
},
computed: {
...mapStores(useCredentialsStore, useNDVStore, useRootStore, useUIStore, useWorkflowsStore),
...mapStores(
useCredentialsStore,
useNDVStore,
useNodeTypesStore,
useRootStore,
useUIStore,
useWorkflowsStore,
),
activeNodeType(): INodeTypeDescription | null {
const activeNode = this.ndvStore.activeNode;
if (activeNode) {
return this.nodeTypesStore.getNodeType(activeNode.type, activeNode.typeVersion);
}
return null;
},
appName(): string {
if (!this.credentialType) {
return '';
@@ -284,8 +318,17 @@ export default mixins(restApi).extend({
!this.authError
);
},
isMissingCredentials(): boolean {
return this.credentialType === null;
},
isNewCredential(): boolean {
return this.mode === 'new' && !this.credentialId;
},
},
methods: {
getCredentialOptions(type: string): ICredentialsResponse[] {
return this.credentialsStore.allUsableCredentialsByType[type];
},
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event);
},
@@ -297,6 +340,9 @@ export default mixins(restApi).extend({
workflow_id: this.workflowsStore.workflowId,
});
},
onAuthTypeChange(newType: string): void {
this.$emit('authTypeChanged', newType);
},
},
watch: {
showOAuthSuccessBanner(newValue, oldValue) {

View File

@@ -12,12 +12,12 @@
<div :class="$style.header">
<div :class="$style.credInfo">
<div :class="$style.credIcon">
<CredentialIcon :credentialTypeName="credentialTypeName" />
<CredentialIcon :credentialTypeName="defaultCredentialTypeName" />
</div>
<InlineNameEdit
:name="credentialName"
:subtitle="credentialType ? credentialType.displayName : ''"
:readonly="!credentialPermissions.updateName"
:readonly="!credentialPermissions.updateName || !credentialType"
type="Credential"
@input="onNameEdit"
data-test-id="credential-name"
@@ -52,7 +52,7 @@
<hr />
</template>
<template #content>
<div :class="$style.container">
<div :class="$style.container" data-test-id="credential-edit-dialog">
<div :class="$style.sidebar">
<n8n-menu mode="tabs" :items="sidebarItems" @select="onTabSelect"></n8n-menu>
</div>
@@ -71,10 +71,14 @@
:parentTypes="parentTypes"
:requiredPropertiesFilled="requiredPropertiesFilled"
:credentialPermissions="credentialPermissions"
:mode="mode"
:selectedCredential="selectedCredential"
:showAuthTypeSelector="requiredCredentials"
@change="onDataChange"
@oauth="oAuthCredentialAuthorize"
@retest="retestCredential"
@scrollToTop="scrollToTop"
@authTypeChanged="onAuthTypeChanged"
/>
</div>
<div v-else-if="activeTab === 'sharing' && credentialType" :class="$style.mainContent">
@@ -107,7 +111,7 @@
<script lang="ts">
import Vue from 'vue';
import type { ICredentialsResponse, IUser } from '@/Interface';
import type { ICredentialsResponse, IUser, NewCredentialsModal } from '@/Interface';
import {
CredentialInformation,
@@ -116,6 +120,7 @@ import {
ICredentialsDecrypted,
ICredentialType,
INode,
INodeCredentialDescription,
INodeParameters,
INodeProperties,
INodeTypeDescription,
@@ -134,12 +139,11 @@ import CredentialSharing from './CredentialSharing.ee.vue';
import SaveButton from '../SaveButton.vue';
import Modal from '../Modal.vue';
import InlineNameEdit from '../InlineNameEdit.vue';
import { EnterpriseEditionFeature } from '@/constants';
import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { IDataObject } from 'n8n-workflow';
import FeatureComingSoon from '../FeatureComingSoon.vue';
import { getCredentialPermissions, IPermissions } from '@/permissions';
import { IMenuItem } from 'n8n-design-system';
import { BaseTextKey } from '@/plugins/i18n';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useSettingsStore } from '@/stores/settings';
@@ -147,7 +151,13 @@ import { useUsersStore } from '@/stores/users';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { useCredentialsStore } from '@/stores/credentials';
import { isValidCredentialResponse } from '@/utils';
import {
isValidCredentialResponse,
getNodeAuthOptions,
getNodeCredentialForSelectedAuthType,
updateNodeAuthType,
isCredentialModalState,
} from '@/utils';
interface NodeAccessMap {
[nodeType: string]: ICredentialNodeAccess | null;
@@ -171,8 +181,8 @@ export default mixins(showMessage, nodeHelpers).extend({
required: true,
},
activeId: {
type: [String],
required: true,
type: [String, Number],
required: false,
},
mode: {
type: String,
@@ -196,22 +206,21 @@ export default mixins(showMessage, nodeHelpers).extend({
testedSuccessfully: false,
isRetesting: false,
EnterpriseEditionFeature,
selectedCredential: '',
requiredCredentials: false, // Are credentials required or optional for the node
hasUserSpecifiedName: false,
};
},
async mounted() {
this.nodeAccess = this.nodesWithAccess.reduce((accu: NodeAccessMap, node: { name: string }) => {
if (this.mode === 'new') {
accu[node.name] = { nodeType: node.name }; // enable all nodes by default
} else {
accu[node.name] = null;
}
this.requiredCredentials =
isCredentialModalState(this.uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY]) &&
this.uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].showAuthSelector === true;
return accu;
}, {});
this.setupNodeAccess();
if (this.mode === 'new' && this.credentialTypeName) {
this.credentialName = await this.credentialsStore.getNewCredentialName({
credentialTypeName: this.credentialTypeName,
credentialTypeName: this.defaultCredentialTypeName,
});
if (this.currentUser) {
@@ -264,6 +273,38 @@ export default mixins(showMessage, nodeHelpers).extend({
useUsersStore,
useWorkflowsStore,
),
activeNodeType(): INodeTypeDescription | null {
const activeNode = this.ndvStore.activeNode;
if (activeNode) {
return this.nodeTypesStore.getNodeType(activeNode.type, activeNode.typeVersion);
}
return null;
},
selectedCredentialType(): INodeCredentialDescription | null {
if (this.mode !== 'new') {
return null;
}
// If there is already selected type, use it
if (this.selectedCredential !== '') {
return this.credentialsStore.getCredentialTypeByName(this.selectedCredential);
} else if (this.requiredCredentials) {
// Otherwise, use credential type that corresponds to the first auth option in the node definition
const nodeAuthOptions = getNodeAuthOptions(this.activeNodeType);
// But only if there is zero or one auth options available
if (nodeAuthOptions.length > 0 && this.activeNodeType?.credentials) {
return getNodeCredentialForSelectedAuthType(
this.activeNodeType,
nodeAuthOptions[0].value,
);
} else {
return this.activeNodeType?.credentials ? this.activeNodeType.credentials[0] : null;
}
}
return null;
},
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
@@ -282,7 +323,9 @@ export default mixins(showMessage, nodeHelpers).extend({
return null;
}
if (this.selectedCredentialType) {
return this.selectedCredentialType.name;
}
return `${this.activeId}`;
},
credentialType(): ICredentialType | null {
@@ -419,6 +462,15 @@ export default mixins(showMessage, nodeHelpers).extend({
isSharingAvailable(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
defaultCredentialTypeName(): string {
let credentialTypeName = this.credentialTypeName;
if (!credentialTypeName || credentialTypeName === 'null') {
if (this.activeNodeType && this.activeNodeType.credentials) {
credentialTypeName = this.activeNodeType.credentials[0].name;
}
}
return credentialTypeName || '';
},
},
methods: {
async beforeClose() {
@@ -614,6 +666,7 @@ export default mixins(showMessage, nodeHelpers).extend({
onNameEdit(text: string) {
this.hasUnsavedChanges = true;
this.hasUserSpecifiedName = true;
this.credentialName = text;
},
@@ -876,6 +929,7 @@ export default mixins(showMessage, nodeHelpers).extend({
this.isDeleting = false;
// Now that the credentials were removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.credentialData = {};
this.$showMessage({
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
@@ -946,6 +1000,50 @@ export default mixins(showMessage, nodeHelpers).extend({
window.addEventListener('message', receiveMessage, false);
},
async onAuthTypeChanged(type: string): Promise<void> {
if (!this.activeNodeType?.credentials) {
return;
}
const credentialsForType = getNodeCredentialForSelectedAuthType(this.activeNodeType, type);
if (credentialsForType) {
this.selectedCredential = credentialsForType.name;
this.resetCredentialData();
this.setupNodeAccess();
// Update current node auth type so credentials dropdown can be displayed properly
updateNodeAuthType(this.ndvStore.activeNode, type);
// Also update credential name but only if the default name is still used
if (this.hasUnsavedChanges && !this.hasUserSpecifiedName) {
const newDefaultName = await this.credentialsStore.getNewCredentialName({
credentialTypeName: this.defaultCredentialTypeName,
});
this.credentialName = newDefaultName;
}
}
},
setupNodeAccess(): void {
this.nodeAccess = this.nodesWithAccess.reduce(
(accu: NodeAccessMap, node: { name: string }) => {
if (this.mode === 'new') {
accu[node.name] = { nodeType: node.name }; // enable all nodes by default
} else {
accu[node.name] = null;
}
return accu;
},
{},
);
},
resetCredentialData(): void {
if (!this.credentialType) {
return;
}
for (const property of this.credentialType.properties) {
if (!this.credentialType.__overwrittenProperties?.includes(property.name)) {
Vue.set(this.credentialData, property.name, property.default as CredentialInformation);
}
}
},
},
});
</script>

View File

@@ -227,6 +227,8 @@ const {
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const { getNodeType } = useNodeTypesStore();
const telemetry = instance?.proxy.$telemetry;
const { categorizedItems: allNodes, isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
@@ -368,18 +370,26 @@ function onActionSelected(actionCreateElement: INodeCreateElement) {
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addHttpNode() {
const app_identifier = state.activeNodeActions?.name;
let nodeCredentialType = '';
const nodeType = app_identifier ? getNodeType(app_identifier) : null;
if (nodeType && nodeType.credentials && nodeType.credentials.length > 0) {
nodeCredentialType = nodeType.credentials[0].name;
}
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
nodeCredentialType,
},
} as IUpdateInformation;
emit('nodeTypeSelected', [MANUAL_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
setAddedNodeActionParameters(updateData, telemetry, false);
const app_identifier = state.activeNodeActions?.name;
$externalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}

View File

@@ -8,17 +8,12 @@
:key="credentialTypeDescription.name"
>
<n8n-input-label
:label="
$locale.baseText('nodeCredentials.credentialFor', {
interpolate: {
credentialType: credentialTypeNames[credentialTypeDescription.name],
},
})
"
:label="getCredentialsFieldLabel(credentialTypeDescription)"
:bold="false"
:set="(issues = getIssues(credentialTypeDescription.name))"
size="small"
color="text-dark"
data-test-id="credentials-label"
>
<div v-if="readonly || isReadOnly">
<n8n-input
@@ -27,20 +22,37 @@
size="small"
/>
</div>
<div v-else :class="issues.length && !hideIssues ? $style.hasIssues : $style.input">
<div
v-else
:class="issues.length && !hideIssues ? $style.hasIssues : $style.input"
data-test-id="node-credentials-select"
>
<n8n-select
:value="getSelectedId(credentialTypeDescription.name)"
@change="(value) => onCredentialSelected(credentialTypeDescription.name, value)"
@change="
(value) =>
onCredentialSelected(
credentialTypeDescription.name,
value,
showMixedCredentials(credentialTypeDescription),
)
"
@blur="$emit('blur', 'credentials')"
:placeholder="getSelectPlaceholder(credentialTypeDescription.name, issues)"
size="small"
>
<n8n-option
v-for="item in getCredentialOptions(credentialTypeDescription.name)"
v-for="item in getCredentialOptions(
getAllRelatedCredentialTypes(credentialTypeDescription),
)"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div :class="[$style.credentialOption, 'mt-2xs', 'mb-2xs']">
<n8n-text bold>{{ item.name }}</n8n-text>
<n8n-text size="small">{{ item.typeDisplayName }}</n8n-text>
</div>
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
@@ -68,6 +80,7 @@
selected[credentialTypeDescription.name] &&
isCredentialExisting(credentialTypeDescription.name)
"
data-test-id="credential-edit-button"
>
<font-awesome-icon
icon="pen"
@@ -91,7 +104,14 @@ import {
INodeUpdatePropertiesInformation,
IUser,
} from '@/Interface';
import { ICredentialType, INodeCredentialDescription, INodeCredentialsDetails } from 'n8n-workflow';
import {
ICredentialType,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeParameters,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import { genericHelpers } from '@/mixins/genericHelpers';
import { nodeHelpers } from '@/mixins/nodeHelpers';
@@ -100,13 +120,26 @@ import { showMessage } from '@/mixins/showMessage';
import TitledList from '@/components/TitledList.vue';
import mixins from 'vue-typed-mixins';
import { getCredentialPermissions } from '@/permissions';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useUsersStore } from '@/stores/users';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useCredentialsStore } from '@/stores/credentials';
import { useNDVStore } from '@/stores/ndv';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import {
getAuthTypeForNodeCredential,
getMainAuthField,
getNodeCredentialForSelectedAuthType,
getAllNodeCredentialForAuthType,
updateNodeAuthType,
isRequiredCredential,
} from '@/utils';
interface CredentialDropdownOption extends ICredentialsResponse {
typeDisplayName: string;
}
export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend({
name: 'NodeCredentials',
@@ -122,6 +155,10 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
overrideCredType: {
type: String,
},
showAll: {
type: Boolean,
default: false,
},
hideIssues: {
type: Boolean,
default: false,
@@ -134,15 +171,101 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
return {
NEW_CREDENTIALS_TEXT: `- ${this.$locale.baseText('nodeCredentials.createNew')} -`,
subscribedToCredentialType: '',
listeningForAuthChange: false,
};
},
mounted() {
this.listenForCredentialUpdates();
// Listen for credentials store changes so credential selection can be updated if creds are changed from the modal
this.credentialsStore.$onAction(({ name, after, store, args }) => {
const listeningForActions = ['createNewCredential', 'updateCredential', 'deleteCredential'];
const credentialType = this.subscribedToCredentialType;
if (!credentialType) {
return;
}
after(async (result) => {
if (!listeningForActions.includes(name)) {
return;
}
const current = this.selected[credentialType];
let credentialsOfType: ICredentialsResponse[] = [];
if (this.showAll) {
if (this.node) {
credentialsOfType = [
...(this.credentialsStore.allUsableCredentialsForNode(this.node) || []),
];
}
} else {
credentialsOfType = [
...(this.credentialsStore.allUsableCredentialsByType[credentialType] || []),
];
}
switch (name) {
// new credential was added
case 'createNewCredential':
if (result) {
this.onCredentialSelected(credentialType, (result as ICredentialsResponse).id);
}
break;
case 'updateCredential':
const updatedCredential = result as ICredentialsResponse;
// credential name was changed, update it
if (updatedCredential.name !== current.name) {
this.onCredentialSelected(credentialType, current.id);
}
break;
case 'deleteCredential':
// all credentials were deleted
if (credentialsOfType.length === 0) {
this.clearSelectedCredential(credentialType);
} else {
const id = args[0].id;
// credential was deleted, select last one added to replace with
if (current.id === id) {
this.onCredentialSelected(
credentialType,
credentialsOfType[credentialsOfType.length - 1].id,
);
}
}
break;
}
});
});
},
watch: {
'node.parameters': {
immediate: true,
deep: true,
handler(newValue: INodeParameters, oldValue: INodeParameters) {
// When active node parameters change, check if authentication type has been changed
// and set `subscribedToCredentialType` to corresponding credential type
const isActive = this.node.name === this.ndvStore.activeNode?.name;
const nodeType = this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
// Only do this for active node and if it's listening for auth change
if (isActive && nodeType && this.listeningForAuthChange) {
if (this.mainNodeAuthField && oldValue && newValue) {
const newAuth = newValue[this.mainNodeAuthField.name];
if (newAuth) {
const credentialType = getNodeCredentialForSelectedAuthType(
nodeType,
newAuth.toString(),
);
if (credentialType) {
this.subscribedToCredentialType = credentialType.name;
}
}
}
}
},
},
},
computed: {
...mapStores(
useCredentialsStore,
useNodeTypesStore,
useNDVStore,
useUIStore,
useUsersStore,
useWorkflowsStore,
@@ -189,11 +312,39 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
selected(): { [type: string]: INodeCredentialsDetails } {
return this.node.credentials || {};
},
nodeType(): INodeTypeDescription | null {
return this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
},
mainNodeAuthField(): INodeProperties | null {
return getMainAuthField(this.nodeType);
},
},
methods: {
getCredentialOptions(type: string): ICredentialsResponse[] {
return this.credentialsStore.allUsableCredentialsByType[type];
getAllRelatedCredentialTypes(credentialType: INodeCredentialDescription): string[] {
const isRequiredCredential = this.showMixedCredentials(credentialType);
if (isRequiredCredential) {
if (this.mainNodeAuthField) {
const credentials = getAllNodeCredentialForAuthType(
this.nodeType,
this.mainNodeAuthField.name,
);
return credentials.map((cred) => cred.name);
}
}
return [credentialType.name];
},
getCredentialOptions(types: string[]): CredentialDropdownOption[] {
let options: CredentialDropdownOption[] = [];
types.forEach((type) => {
options = options.concat(
this.credentialsStore.allUsableCredentialsByType[type].map((option: any) => ({
...option,
typeDisplayName: this.credentialsStore.getCredentialTypeByName(type).displayName,
})),
);
});
return options;
},
getSelectedId(type: string) {
if (this.isCredentialExisting(type)) {
@@ -226,70 +377,6 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
return styles;
},
// Listen for credentials store changes so credential selection can be updated if creds are changed from the modal
listenForCredentialUpdates() {
const getCounts = () => {
return Object.keys(this.credentialsStore.allUsableCredentialsByType).reduce(
(counts: { [key: string]: number }, key: string) => {
counts[key] = this.credentialsStore.allUsableCredentialsByType[key].length;
return counts;
},
{},
);
};
let previousCredentialCounts = getCounts();
const onCredentialMutation = () => {
// This data pro stores credential type that the component is currently interested in
const credentialType = this.subscribedToCredentialType;
if (!credentialType) {
return;
}
let credentialsOfType = [
...(this.credentialsStore.allUsableCredentialsByType[credentialType] || []),
];
// all credentials were deleted
if (credentialsOfType.length === 0) {
this.clearSelectedCredential(credentialType);
return;
}
credentialsOfType = credentialsOfType.sort((a, b) => (a.id < b.id ? -1 : 1));
const previousCredsOfType = previousCredentialCounts[credentialType] || 0;
const current = this.selected[credentialType];
// new credential was added
if (credentialsOfType.length > previousCredsOfType || !current) {
this.onCredentialSelected(
credentialType,
credentialsOfType[credentialsOfType.length - 1].id,
);
return;
}
const matchingCredential = credentialsOfType.find((cred) => cred.id === current.id);
// credential was deleted, select last one added to replace with
if (!matchingCredential) {
this.onCredentialSelected(
credentialType,
credentialsOfType[credentialsOfType.length - 1].id,
);
return;
}
// credential was updated
if (matchingCredential.name !== current.name) {
// credential name was changed, update it
this.onCredentialSelected(credentialType, current.id);
}
};
this.credentialsStore.$subscribe((mutation, state) => {
onCredentialMutation();
previousCredentialCounts = getCounts();
});
},
clearSelectedCredential(credentialType: string) {
const node: INodeUi = this.node;
@@ -310,12 +397,19 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
this.$emit('credentialSelected', updateInformation);
},
onCredentialSelected(credentialType: string, credentialId: string | null | undefined) {
onCredentialSelected(
credentialType: string,
credentialId: string | null | undefined,
requiredCredentials = false,
) {
if (credentialId === this.NEW_CREDENTIALS_TEXT) {
// If new credential dialog is open, start listening for auth type change which should happen in the modal
// this will be handled in this component's watcher which will set subscribed credential accordingly
this.listeningForAuthChange = true;
this.subscribedToCredentialType = credentialType;
}
if (!credentialId || credentialId === this.NEW_CREDENTIALS_TEXT) {
this.uiStore.openNewCredential(credentialType);
this.uiStore.openNewCredential(credentialType, requiredCredentials);
this.$telemetry.track('User opened Credential modal', {
credential_type: credentialType,
source: 'node',
@@ -334,9 +428,10 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
});
const selectedCredentials = this.credentialsStore.getCredentialById(credentialId);
const selectedCredentialsType = this.showAll ? selectedCredentials.type : credentialType;
const oldCredentials =
this.node.credentials && this.node.credentials[credentialType]
? this.node.credentials[credentialType]
this.node.credentials && this.node.credentials[selectedCredentialsType]
? this.node.credentials[selectedCredentialsType]
: {};
const selected = { id: selectedCredentials.id, name: selectedCredentials.name };
@@ -345,13 +440,16 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
if (
oldCredentials.id === null ||
(oldCredentials.id &&
!this.credentialsStore.getCredentialByIdAndType(oldCredentials.id, credentialType))
!this.credentialsStore.getCredentialByIdAndType(
oldCredentials.id,
selectedCredentialsType,
))
) {
// update all nodes in the workflow with the same old/invalid credentials
this.workflowsStore.replaceInvalidWorkflowCredentials({
credentials: selected,
invalid: oldCredentials,
type: credentialType,
type: selectedCredentialsType,
});
this.updateNodesCredentialsIssues();
this.$showMessage({
@@ -366,11 +464,27 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
});
}
// If credential is selected from mixed credential dropdown, update node's auth filed based on selected credential
if (this.showAll && this.mainNodeAuthField) {
const nodeCredentialDescription = this.nodeType?.credentials?.find(
(cred) => cred.name === selectedCredentialsType,
);
const authOption = getAuthTypeForNodeCredential(this.nodeType, nodeCredentialDescription);
if (authOption) {
updateNodeAuthType(this.node, authOption.value);
const parameterData = {
name: `parameters.${this.mainNodeAuthField.name}`,
value: authOption.value,
};
this.$emit('valueChanged', parameterData);
}
}
const node: INodeUi = this.node;
const credentials = {
...(node.credentials || {}),
[credentialType]: selected,
[selectedCredentialsType]: selected,
};
const updateInformation: INodeUpdatePropertiesInformation = {
@@ -401,7 +515,6 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
if (!node.issues.credentials.hasOwnProperty(credentialTypeName)) {
return [];
}
return node.issues.credentials[credentialTypeName];
},
@@ -414,7 +527,7 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
return false;
}
const { id } = this.node.credentials[credentialType];
const options = this.getCredentialOptions(credentialType);
const options = this.getCredentialOptions([credentialType]);
return !!options.find((option: ICredentialsResponse) => option.id === id);
},
@@ -431,6 +544,24 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
});
this.subscribedToCredentialType = credentialType;
},
showMixedCredentials(credentialType: INodeCredentialDescription): boolean {
const nodeType = this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
const isRequired = isRequiredCredential(nodeType, credentialType);
return !KEEP_AUTH_IN_NDV_FOR_NODES.includes(this.node.type || '') && isRequired;
},
getCredentialsFieldLabel(credentialType: INodeCredentialDescription): string {
const credentialTypeName = this.credentialTypeNames[credentialType.name];
if (!this.showMixedCredentials(credentialType)) {
return this.$locale.baseText('nodeCredentials.credentialFor', {
interpolate: {
credentialType: credentialTypeName,
},
});
}
return this.$locale.baseText('nodeCredentials.credentialsLabel');
},
},
});
</script>
@@ -470,4 +601,9 @@ export default mixins(genericHelpers, nodeHelpers, restApi, showMessage).extend(
composes: input;
--input-border-color: var(--color-danger);
}
.credentialOption {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -103,7 +103,9 @@
<node-credentials
:node="node"
:readonly="isReadOnly"
:showAll="true"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@blur="onParameterBlur"
:hide-issues="hiddenIssuesInputs.includes('credentials')"
/>

View File

@@ -1,6 +1,11 @@
<template>
<div class="parameter-input-list-wrapper">
<div v-for="(parameter, index) in filteredParameters" :key="parameter.name" :class="{ indent }">
<div
v-for="(parameter, index) in filteredParameters"
:key="parameter.name"
:class="{ indent }"
data-test-id="parameter-item"
>
<slot v-if="indexToShowSlotAt === index" />
<div
@@ -126,6 +131,8 @@ import { Component, PropType } from 'vue';
import { mapStores } from 'pinia';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { isAuthRelatedParameter, getNodeAuthFields, getMainAuthField } from '@/utils';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
export default mixins(workflowHelpers).extend({
name: 'ParameterInputList',
@@ -180,6 +187,12 @@ export default mixins(workflowHelpers).extend({
}
return '';
},
nodeType(): INodeTypeDescription | null {
if (this.node) {
return this.nodeTypesStore.getNodeType(this.node.type, this.node.typeVersion);
}
return null;
},
filteredParameters(): INodeProperties[] {
return this.parameters.filter((parameter: INodeProperties) =>
this.displayNodeParameter(parameter),
@@ -191,18 +204,27 @@ export default mixins(workflowHelpers).extend({
node(): INodeUi | null {
return this.ndvStore.activeNode;
},
nodeAuthFields(): INodeProperties[] {
return getNodeAuthFields(this.nodeType);
},
indexToShowSlotAt(): number {
let index = 0;
// For nodes that use old credentials UI, keep credentials below authentication field in NDV
// otherwise credentials will use auth filed position since the auth field is moved to credentials modal
const fieldOffset = KEEP_AUTH_IN_NDV_FOR_NODES.includes(this.nodeType?.name || '') ? 1 : 0;
const credentialsDependencies = this.getCredentialsDependencies();
this.filteredParameters.forEach((prop, propIndex) => {
if (credentialsDependencies.has(prop.name)) {
index = propIndex + 1;
index = propIndex + fieldOffset;
}
});
return index < this.filteredParameters.length ? index : this.filteredParameters.length - 1;
},
mainNodeAuthField(): INodeProperties | null {
return getMainAuthField(this.nodeType || undefined);
},
},
methods: {
onParameterBlur(parameterName: string) {
@@ -273,7 +295,6 @@ export default mixins(workflowHelpers).extend({
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
},
displayNodeParameter(parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') {
return false;
@@ -286,6 +307,16 @@ export default mixins(workflowHelpers).extend({
return false;
}
// Hide authentication related fields since it will now be part of credentials modal
if (
!KEEP_AUTH_IN_NDV_FOR_NODES.includes(this.node?.type || '') &&
this.mainNodeAuthField &&
(parameter.name === this.mainNodeAuthField?.name ||
this.shouldHideAuthRelatedParameter(parameter))
) {
return false;
}
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
@@ -357,6 +388,15 @@ export default mixins(workflowHelpers).extend({
this.$emit('activate');
}
},
isNodeAuthField(name: string): boolean {
return this.nodeAuthFields.find((field) => field.name === name) !== undefined;
},
shouldHideAuthRelatedParameter(parameter: INodeProperties): boolean {
// TODO: For now, hide all fields that are used in authentication fields displayOptions
// Ideally, we should check if any non-auth field depends on it before hiding it but
// since there is no such case, omitting it to avoid additional computation
return isAuthRelatedParameter(this.nodeAuthFields, parameter);
},
},
watch: {
filteredParameterNames(newValue, oldValue) {

View File

@@ -53,7 +53,7 @@ import {
NodeParameterValue,
NodeParameterValueType,
} from 'n8n-workflow';
import { INodeUi, IUiState, IUpdateInformation, TargetItem } from '@/Interface';
import { INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { isValueExpression } from '@/utils';
import { mapStores } from 'pinia';

View File

@@ -42,7 +42,7 @@ export default Vue.extend({
type: Boolean,
},
value: {
type: [Object, String, Number, Boolean] as PropType<NodeParameterValueType>,
type: [Object, String, Number, Boolean, Array] as PropType<NodeParameterValueType>,
},
showOptions: {
type: Boolean,