Files
Automata/packages/editor-ui/src/components/NodeCredentials.vue
oleg 184ed8e17d refactor: Migrate genericHelpers mixin to composable (#8220)
## Summary
- Moved out canvas loading handling to canvas store
- Tag editable routes via meta to remove router dependency from generic
helpers
- Replace all occurrences of `genericHelpers` mixin with composable and
audit usage
- Moved out `isRedirectSafe` and `getRedirectQueryParameter` out of
genericHelpers to remove dependency on router

Removing the router dependency is important, because `useRouter` and
`useRoute` compostables are only available if called from component
instance. So if composable is nested within another composable, we
wouldn't be able to use these. In this case we'd always need to inject
the router and pass it through several composables. That's why I moved
the `readonly` logic to router meta and `isRedirectSafe` and
`getRedirectQueryParameter` out as they were only used in a single
component.

---------

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
2024-01-05 12:23:28 +01:00

625 lines
18 KiB
Vue

<template>
<div
v-if="credentialTypesNodeDescriptionDisplayed.length"
:class="['node-credentials', $style.container]"
>
<div
v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed"
:key="credentialTypeDescription.name"
>
<n8n-input-label
:label="getCredentialsFieldLabel(credentialTypeDescription)"
:bold="false"
:set="(issues = getIssues(credentialTypeDescription.name))"
size="small"
color="text-dark"
data-test-id="credentials-label"
>
<div v-if="readonly">
<n8n-input
:model-value="getSelectedName(credentialTypeDescription.name)"
disabled
size="small"
data-test-id="node-credentials-select"
/>
</div>
<div
v-else
:class="issues.length && !hideIssues ? $style.hasIssues : $style.input"
data-test-id="node-credentials-select"
>
<n8n-select
:model-value="getSelectedId(credentialTypeDescription.name)"
:placeholder="getSelectPlaceholder(credentialTypeDescription.name, issues)"
size="small"
@update:modelValue="
(value) =>
onCredentialSelected(
credentialTypeDescription.name,
value,
showMixedCredentials(credentialTypeDescription),
)
"
@blur="$emit('blur', 'credentials')"
>
<n8n-option
v-for="item in getCredentialOptions(
getAllRelatedCredentialTypes(credentialTypeDescription),
)"
:key="item.id"
:data-test-id="`node-credentials-select-item-${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"
data-test-id="node-credentials-select-item-new"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
<div v-if="issues.length && !hideIssues" :class="$style.warning">
<n8n-tooltip placement="top">
<template #content>
<TitledList
:title="`${$locale.baseText('nodeCredentials.issues')}:`"
:items="issues"
/>
</template>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<div
v-if="
selected[credentialTypeDescription.name] &&
isCredentialExisting(credentialTypeDescription.name)
"
:class="$style.edit"
data-test-id="credential-edit-button"
>
<font-awesome-icon
icon="pen"
class="clickable"
:title="$locale.baseText('nodeCredentials.updateCredential')"
@click="editCredential(credentialTypeDescription.name)"
/>
</div>
</div>
</n8n-input-label>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type {
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IUser,
} from '@/Interface';
import type {
ICredentialType,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeParameters,
INodeProperties,
INodeTypeDescription,
} from 'n8n-workflow';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useToast } from '@/composables/useToast';
import TitledList from '@/components/TitledList.vue';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNDVStore } from '@/stores/ndv.store';
import { CREDENTIAL_ONLY_NODE_PREFIX, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import {
getAuthTypeForNodeCredential,
getMainAuthField,
getNodeCredentialForSelectedAuthType,
getAllNodeCredentialForAuthType,
updateNodeAuthType,
isRequiredCredential,
} from '@/utils/nodeTypesUtils';
interface CredentialDropdownOption extends ICredentialsResponse {
typeDisplayName: string;
}
export default defineComponent({
name: 'NodeCredentials',
components: {
TitledList,
},
props: {
readonly: {
type: Boolean,
default: false,
},
node: {
type: Object as PropType<INodeUi>,
required: true,
},
overrideCredType: {
type: String,
},
showAll: {
type: Boolean,
default: false,
},
hideIssues: {
type: Boolean,
default: false,
},
},
setup() {
const nodeHelpers = useNodeHelpers();
return {
...useToast(),
nodeHelpers,
};
},
data() {
return {
NEW_CREDENTIALS_TEXT: `- ${this.$locale.baseText('nodeCredentials.createNew')} -`,
subscribedToCredentialType: '',
listeningForAuthChange: false,
};
},
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.nodeType;
// 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;
}
}
}
}
},
},
},
mounted() {
// 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;
}
});
});
},
computed: {
...mapStores(
useCredentialsStore,
useNodeTypesStore,
useNDVStore,
useUIStore,
useUsersStore,
useWorkflowsStore,
),
currentUser(): IUser {
return this.usersStore.currentUser || ({} as IUser);
},
credentialTypesNode(): string[] {
return this.credentialTypesNodeDescription.map(
(credentialTypeDescription) => credentialTypeDescription.name,
);
},
credentialTypesNodeDescriptionDisplayed(): INodeCredentialDescription[] {
return this.credentialTypesNodeDescription.filter((credentialTypeDescription) => {
return this.displayCredentials(credentialTypeDescription);
});
},
credentialTypesNodeDescription(): INodeCredentialDescription[] {
const credType = this.credentialsStore.getCredentialTypeByName(this.overrideCredType);
if (credType) return [credType];
const activeNodeType = this.nodeType;
if (activeNodeType?.credentials) {
return activeNodeType.credentials;
}
return [];
},
credentialTypeNames() {
const returnData: {
[key: string]: string;
} = {};
let credentialType: ICredentialType | undefined;
for (const credentialTypeName of this.credentialTypesNode) {
credentialType = this.credentialsStore.getCredentialTypeByName(credentialTypeName);
returnData[credentialTypeName] = credentialType
? credentialType.displayName
: credentialTypeName;
}
return returnData;
},
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: {
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: ICredentialsResponse) =>
({
...option,
typeDisplayName: this.credentialsStore.getCredentialTypeByName(type)?.displayName,
}) as CredentialDropdownOption,
),
);
});
return options;
},
getSelectedId(type: string) {
if (this.isCredentialExisting(type)) {
return this.selected[type].id;
}
return undefined;
},
getSelectedName(type: string) {
return this.selected?.[type]?.name;
},
getSelectPlaceholder(type: string, issues: string[]) {
return issues.length && this.getSelectedName(type)
? this.$locale.baseText('nodeCredentials.selectedCredentialUnavailable', {
interpolate: { name: this.getSelectedName(type) },
})
: this.$locale.baseText('nodeCredentials.selectCredential');
},
credentialInputWrapperStyle(credentialType: string) {
let deductWidth = 0;
const styles = {
width: '100%',
};
if (this.getIssues(credentialType).length) {
deductWidth += 20;
}
if (deductWidth !== 0) {
styles.width = `calc(100% - ${deductWidth}px)`;
}
return styles;
},
clearSelectedCredential(credentialType: string) {
const node: INodeUi = this.node;
const credentials = {
...(node.credentials || {}),
};
delete credentials[credentialType];
const updateInformation: INodeUpdatePropertiesInformation = {
name: this.node.name,
properties: {
credentials,
},
};
this.$emit('credentialSelected', updateInformation);
},
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, requiredCredentials);
this.$telemetry.track('User opened Credential modal', {
credential_type: credentialType,
source: 'node',
new_credential: true,
workflow_id: this.workflowsStore.workflowId,
});
return;
}
this.$telemetry.track('User selected credential from node modal', {
credential_type: credentialType,
node_type: this.node.type,
...(this.nodeHelpers.hasProxyAuth(this.node) ? { is_service_specific: true } : {}),
workflow_id: this.workflowsStore.workflowId,
credential_id: credentialId,
});
const selectedCredentials = this.credentialsStore.getCredentialById(credentialId);
const selectedCredentialsType = this.showAll ? selectedCredentials.type : credentialType;
const oldCredentials = this.node.credentials?.[selectedCredentialsType]
? this.node.credentials[selectedCredentialsType]
: {};
const selected = { id: selectedCredentials.id, name: selectedCredentials.name };
// if credentials has been string or neither id matched nor name matched uniquely
if (
oldCredentials.id === null ||
(oldCredentials.id &&
!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: selectedCredentialsType,
});
this.nodeHelpers.updateNodesCredentialsIssues();
this.showMessage({
title: this.$locale.baseText('nodeCredentials.showMessage.title'),
message: this.$locale.baseText('nodeCredentials.showMessage.message', {
interpolate: {
oldCredentialName: oldCredentials.name,
newCredentialName: selected.name,
},
}),
type: 'success',
});
}
// 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 || {}),
[selectedCredentialsType]: selected,
};
const updateInformation: INodeUpdatePropertiesInformation = {
name: this.node.name,
properties: {
credentials,
},
};
this.$emit('credentialSelected', updateInformation);
},
displayCredentials(credentialTypeDescription: INodeCredentialDescription): boolean {
if (credentialTypeDescription.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.nodeHelpers.displayParameter(
this.node.parameters,
credentialTypeDescription,
'',
this.node,
);
},
getIssues(credentialTypeName: string): string[] {
const node = this.node;
if (node.issues?.credentials === undefined) {
return [];
}
if (!node.issues.credentials.hasOwnProperty(credentialTypeName)) {
return [];
}
return node.issues.credentials[credentialTypeName];
},
isCredentialExisting(credentialType: string): boolean {
if (!this.node.credentials?.[credentialType]?.id) {
return false;
}
const { id } = this.node.credentials[credentialType];
const options = this.getCredentialOptions([credentialType]);
return !!options.find((option: ICredentialsResponse) => option.id === id);
},
editCredential(credentialType: string): void {
const { id } = this.node.credentials[credentialType];
this.uiStore.openExistingCredential(id);
this.$telemetry.track('User opened Credential modal', {
credential_type: credentialType,
source: 'node',
new_credential: false,
workflow_id: this.workflowsStore.workflowId,
});
this.subscribedToCredentialType = credentialType;
},
showMixedCredentials(credentialType: INodeCredentialDescription): boolean {
const nodeType = this.nodeType;
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];
const isCredentialOnlyNode = this.node.type.startsWith(CREDENTIAL_ONLY_NODE_PREFIX);
if (isCredentialOnlyNode) {
return this.$locale.baseText('nodeCredentials.credentialFor', {
interpolate: { credentialType: this.nodeType?.displayName ?? credentialTypeName },
});
}
if (!this.showMixedCredentials(credentialType)) {
return this.$locale.baseText('nodeCredentials.credentialFor', {
interpolate: { credentialType: credentialTypeName },
});
}
return this.$locale.baseText('nodeCredentials.credentialsLabel');
},
},
});
</script>
<style lang="scss" module>
.container {
margin-top: var(--spacing-xs);
& > div:not(:first-child) {
margin-top: var(--spacing-xs);
}
}
.warning {
min-width: 20px;
margin-left: 5px;
color: #ff8080;
font-size: var(--font-size-s);
}
.edit {
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
min-width: 20px;
margin-left: 5px;
font-size: var(--font-size-s);
}
.input {
display: flex;
align-items: center;
}
.hasIssues {
composes: input;
--input-border-color: var(--color-danger);
}
.credentialOption {
display: flex;
flex-direction: column;
}
</style>