fix: Fix credential setup for templates with unnamed credentials (no-changelog) (#7891)

The template credentials were matched before solely based on the
credential name on the template. This fixes the matching to use both
credential type name and credential name.
This commit is contained in:
Tomi Turtiainen 2023-12-01 14:56:51 +02:00 committed by GitHub
parent bcd04981ef
commit f8e93b3852
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 249 additions and 71 deletions

View file

@ -0,0 +1,47 @@
import {
keyFromCredentialTypeAndName,
replaceAllTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
describe('templateTransforms', () => {
describe('replaceAllTemplateNodeCredentials', () => {
it('should replace credentials of nodes that have credentials', () => {
const node = newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
credentials: {
twitterOAuth1Api: 'old1',
},
});
const toReplaceWith = {
[keyFromCredentialTypeAndName('twitterOAuth1Api', 'old1')]: {
id: 'new1',
name: 'Twitter creds',
},
};
const [replacedNode] = replaceAllTemplateNodeCredentials([node], toReplaceWith);
expect(replacedNode.credentials).toEqual({
twitterOAuth1Api: { id: 'new1', name: 'Twitter creds' },
});
});
it('should not replace credentials of nodes that do not have credentials', () => {
const node = newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
});
const toReplaceWith = {
[keyFromCredentialTypeAndName('twitterOAuth1Api', 'old1')]: {
id: 'new1',
name: 'Twitter creds',
},
};
const [replacedNode] = replaceAllTemplateNodeCredentials([node], toReplaceWith);
expect(replacedNode.credentials).toBeUndefined();
});
});
});

View file

@ -5,6 +5,7 @@ import type { useRootStore } from '@/stores/n8nRoot.store';
import type { useWorkflowsStore } from '@/stores/workflows.store'; import type { useWorkflowsStore } from '@/stores/workflows.store';
import { FeatureFlag, isFeatureFlagEnabled } from '@/utils/featureFlag'; import { FeatureFlag, isFeatureFlagEnabled } from '@/utils/featureFlag';
import { getFixedNodesList } from '@/utils/nodeViewUtils'; import { getFixedNodesList } from '@/utils/nodeViewUtils';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms'; import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
import type { INodeCredentialsDetails } from 'n8n-workflow'; import type { INodeCredentialsDetails } from 'n8n-workflow';
import type { RouteLocationRaw, Router } from 'vue-router'; import type { RouteLocationRaw, Router } from 'vue-router';
@ -14,7 +15,7 @@ import type { RouteLocationRaw, Router } from 'vue-router';
*/ */
export async function createWorkflowFromTemplate( export async function createWorkflowFromTemplate(
template: IWorkflowTemplate, template: IWorkflowTemplate,
credentialOverrides: Record<string, INodeCredentialsDetails>, credentialOverrides: Record<TemplateCredentialKey, INodeCredentialsDetails>,
rootStore: ReturnType<typeof useRootStore>, rootStore: ReturnType<typeof useRootStore>,
workflowsStore: ReturnType<typeof useWorkflowsStore>, workflowsStore: ReturnType<typeof useWorkflowsStore>,
) { ) {

View file

@ -5,6 +5,24 @@ import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode & export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode &
Required<Pick<IWorkflowTemplateNode, 'credentials'>>; Required<Pick<IWorkflowTemplateNode, 'credentials'>>;
const credentialKeySymbol = Symbol('credentialKey');
/**
* A key that uniquely identifies a credential in a template node.
* It encodes the credential type name and the credential name.
* Uses a symbol typing trick to get nominal typing.
* Use `keyFromCredentialTypeAndName` to create a key.
*/
export type TemplateCredentialKey = string & { [credentialKeySymbol]: never };
/**
* Forms a key from credential type name and credential name
*/
export const keyFromCredentialTypeAndName = (
credentialTypeName: string,
credentialName: string,
): TemplateCredentialKey => `${credentialTypeName}-${credentialName}` as TemplateCredentialKey;
/** /**
* Checks if a template workflow node has credentials defined * Checks if a template workflow node has credentials defined
*/ */
@ -32,16 +50,16 @@ export const normalizeTemplateNodeCredentials = (
* *
* @example * @example
* const nodeCredentials = { twitterOAuth1Api: "twitter" }; * const nodeCredentials = { twitterOAuth1Api: "twitter" };
* const toReplaceByType = { twitter: { * const toReplaceByKey = { 'twitterOAuth1Api-twitter': {
* id: "BrEOZ5Cje6VYh9Pc", * id: "BrEOZ5Cje6VYh9Pc",
* name: "X OAuth account" * name: "X OAuth account"
* }}; * }};
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByType); * replaceTemplateNodeCredentials(nodeCredentials, toReplaceByKey);
* // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } } * // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } }
*/ */
export const replaceTemplateNodeCredentials = ( export const replaceTemplateNodeCredentials = (
nodeCredentials: IWorkflowTemplateNodeCredentials, nodeCredentials: IWorkflowTemplateNodeCredentials,
toReplaceByName: Record<string, INodeCredentialsDetails>, toReplaceByKey: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => { ) => {
if (!nodeCredentials) { if (!nodeCredentials) {
return undefined; return undefined;
@ -51,7 +69,8 @@ export const replaceTemplateNodeCredentials = (
const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials); const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials);
for (const credentialType in normalizedCredentials) { for (const credentialType in normalizedCredentials) {
const credentialNameInTemplate = normalizedCredentials[credentialType]; const credentialNameInTemplate = normalizedCredentials[credentialType];
const toReplaceWith = toReplaceByName[credentialNameInTemplate]; const key = keyFromCredentialTypeAndName(credentialType, credentialNameInTemplate);
const toReplaceWith = toReplaceByKey[key];
if (toReplaceWith) { if (toReplaceWith) {
newNodeCredentials[credentialType] = toReplaceWith; newNodeCredentials[credentialType] = toReplaceWith;
} }
@ -66,7 +85,7 @@ export const replaceTemplateNodeCredentials = (
*/ */
export const replaceAllTemplateNodeCredentials = ( export const replaceAllTemplateNodeCredentials = (
nodes: IWorkflowTemplateNode[], nodes: IWorkflowTemplateNode[],
toReplaceWith: Record<string, INodeCredentialsDetails>, toReplaceWith: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => { ) => {
return nodes.map((node) => { return nodes.map((node) => {
if (hasNodeCredentials(node)) { if (hasNodeCredentials(node)) {

View file

@ -0,0 +1,16 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker/locale/en';
import type { IWorkflowTemplateNode } from '@/Interface';
export const newWorkflowTemplateNode = ({
type,
...optionalOpts
}: Pick<IWorkflowTemplateNode, 'type'> &
Partial<IWorkflowTemplateNode>): IWorkflowTemplateNode => ({
type,
name: faker.commerce.productName(),
position: [0, 0],
parameters: {},
typeVersion: 1,
...optionalOpts,
});

View file

@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { PropType } from 'vue';
import { computed } from 'vue'; import { computed } from 'vue';
import N8nHeading from 'n8n-design-system/components/N8nHeading'; import N8nHeading from 'n8n-design-system/components/N8nHeading';
import NodeIcon from '@/components/NodeIcon.vue'; import NodeIcon from '@/components/NodeIcon.vue';
import CredentialPicker from '@/components/CredentialPicker/CredentialPicker.vue'; import CredentialPicker from '@/components/CredentialPicker/CredentialPicker.vue';
import IconSuccess from './IconSuccess.vue'; import IconSuccess from './IconSuccess.vue';
import { assert } from '@/utils/assert';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils'; import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import { formatList } from '@/utils/formatters/listFormatter'; import { formatList } from '@/utils/formatters/listFormatter';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store'; import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import type { IWorkflowTemplateNode } from '@/Interface'; import type { IWorkflowTemplateNode } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
@ -18,8 +19,8 @@ const props = defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
credentialName: { credentials: {
type: String, type: Object as PropType<CredentialUsages>,
required: true, required: true,
}, },
}); });
@ -31,34 +32,26 @@ const i18n = useI18n();
//#region Computed //#region Computed
const credentials = computed(() => { const node = computed(() => props.credentials.usedBy[0]);
const credential = setupTemplateStore.credentialsByName.get(props.credentialName);
assert(credential);
return credential;
});
const node = computed(() => credentials.value.usedBy[0]);
const nodeType = computed(() => const nodeType = computed(() =>
nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion), nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
); );
const credentialType = computed(() => credentials.value.credentialType);
const appName = computed(() => const appName = computed(() =>
nodeType.value ? getAppNameFromNodeName(nodeType.value.displayName) : node.value.type, nodeType.value ? getAppNameFromNodeName(nodeType.value.displayName) : node.value.type,
); );
const nodeNames = computed(() => { const nodeNames = computed(() => {
const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`; const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`;
return formatList(credentials.value.usedBy, { return formatList(props.credentials.usedBy, {
formatFn: formatNodeName, formatFn: formatNodeName,
i18n, i18n,
}); });
}); });
const selectedCredentialId = computed( const selectedCredentialId = computed(
() => setupTemplateStore.selectedCredentialIdByName[props.credentialName], () => setupTemplateStore.selectedCredentialIdByKey[props.credentials.key],
); );
//#endregion Computed //#endregion Computed
@ -66,11 +59,11 @@ const selectedCredentialId = computed(
//#region Methods //#region Methods
const onCredentialSelected = (credentialId: string) => { const onCredentialSelected = (credentialId: string) => {
setupTemplateStore.setSelectedCredentialId(props.credentialName, credentialId); setupTemplateStore.setSelectedCredentialId(props.credentials.key, credentialId);
}; };
const onCredentialDeselected = () => { const onCredentialDeselected = () => {
setupTemplateStore.unsetSelectedCredential(props.credentialName); setupTemplateStore.unsetSelectedCredential(props.credentials.key);
}; };
//#endregion Methods //#endregion Methods
@ -101,7 +94,7 @@ const onCredentialDeselected = () => {
<CredentialPicker <CredentialPicker
:class="$style.credentialPicker" :class="$style.credentialPicker"
:app-name="appName" :app-name="appName"
:credentialType="credentialType" :credentialType="props.credentials.credentialType"
:selectedCredentialId="selectedCredentialId" :selectedCredentialId="selectedCredentialId"
@credential-selected="onCredentialSelected" @credential-selected="onCredentialSelected"
@credential-deselected="onCredentialDeselected" @credential-deselected="onCredentialDeselected"

View file

@ -120,11 +120,10 @@ onMounted(async () => {
<ol v-if="isReady" :class="$style.appCredentialsContainer"> <ol v-if="isReady" :class="$style.appCredentialsContainer">
<SetupTemplateFormStep <SetupTemplateFormStep
:class="$style.appCredential" :class="$style.appCredential"
v-bind:key="credentials.credentialName" :key="credentials.key"
v-for="(credentials, index) in setupTemplateStore.credentialUsages" v-for="(credentials, index) in setupTemplateStore.credentialUsages"
:order="index + 1" :order="index + 1"
:credentials="credentials" :credentials="credentials"
:credentialName="credentials.credentialName"
/> />
</ol> </ol>
<div v-else :class="$style.appCredentialsContainer"> <div v-else :class="$style.appCredentialsContainer">

View file

@ -1,19 +1,24 @@
import { useTemplatesStore } from '@/stores/templates.store'; import { useTemplatesStore } from '@/stores/templates.store';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms'; import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
import type {
TemplateCredentialKey,
IWorkflowTemplateNodeWithCredentials,
} from '@/utils/templates/templateTransforms';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store'; import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { import {
getAppCredentials, getAppCredentials,
getAppsRequiringCredentials, getAppsRequiringCredentials,
groupNodeCredentialsByName,
useSetupTemplateStore, useSetupTemplateStore,
groupNodeCredentialsByKey,
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store'; } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import * as testData from './setupTemplate.store.testData'; import * as testData from './setupTemplate.store.testData';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
const objToMap = <T>(obj: Record<string, T>) => { const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map<string, T>(Object.entries(obj)); return new Map(Object.entries(obj)) as Map<TKey, T>;
}; };
describe('SetupWorkflowFromTemplateView store', () => { describe('SetupWorkflowFromTemplateView store', () => {
@ -60,27 +65,30 @@ describe('SetupWorkflowFromTemplateView store', () => {
}, },
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>; } satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
describe('groupNodeCredentialsByName', () => { describe('groupNodeCredentialsByTypeAndName', () => {
it('returns an empty array if there are no nodes', () => { it('returns an empty array if there are no nodes', () => {
expect(groupNodeCredentialsByName([])).toEqual(new Map()); expect(groupNodeCredentialsByKey([])).toEqual(new Map());
}); });
it('returns credentials grouped by name', () => { it('returns credentials grouped by type and name', () => {
expect(groupNodeCredentialsByName(Object.values(nodesByName))).toEqual( expect(groupNodeCredentialsByKey(Object.values(nodesByName))).toEqual(
objToMap({ objToMap({
twitter: { 'twitterOAuth1Api-twitter': {
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter', credentialName: 'twitter',
credentialType: 'twitterOAuth1Api', credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter', nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter], usedBy: [nodesByName.Twitter],
}, },
telegram: { 'telegramApi-telegram': {
key: 'telegramApi-telegram',
credentialName: 'telegram', credentialName: 'telegram',
credentialType: 'telegramApi', credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram', nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [nodesByName.Telegram], usedBy: [nodesByName.Telegram],
}, },
shopify: { 'shopifyApi-shopify': {
key: 'shopifyApi-shopify',
credentialName: 'shopify', credentialName: 'shopify',
credentialType: 'shopifyApi', credentialType: 'shopifyApi',
nodeTypeName: 'n8n-nodes-base.shopifyTrigger', nodeTypeName: 'n8n-nodes-base.shopifyTrigger',
@ -89,6 +97,42 @@ describe('SetupWorkflowFromTemplateView store', () => {
}), }),
); );
}); });
it('returns credentials grouped when the credential names are the same', () => {
const [node1, node2] = [
newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
credentials: {
twitterOAuth1Api: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
newWorkflowTemplateNode({
type: 'n8n-nodes-base.telegram',
credentials: {
telegramApi: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
];
expect(groupNodeCredentialsByKey([node1, node2])).toEqual(
objToMap({
'twitterOAuth1Api-credential': {
key: 'twitterOAuth1Api-credential',
credentialName: 'credential',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [node1],
},
'telegramApi-credential': {
key: 'telegramApi-credential',
credentialName: 'credential',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [node2],
},
}),
);
});
}); });
describe('getAppsRequiringCredentials', () => { describe('getAppsRequiringCredentials', () => {
@ -98,8 +142,9 @@ describe('SetupWorkflowFromTemplateView store', () => {
}); });
it('returns an array of apps requiring credentials', () => { it('returns an array of apps requiring credentials', () => {
const credentialUsages: Map<string, CredentialUsages> = objToMap({ const credentialUsages = objToMap<TemplateCredentialKey, CredentialUsages>({
twitter: { [keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter')]: {
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter', credentialName: 'twitter',
credentialType: 'twitterOAuth1Api', credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter', nodeTypeName: 'n8n-nodes-base.twitter',
@ -127,6 +172,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
it('returns an array of apps requiring credentials', () => { it('returns an array of apps requiring credentials', () => {
const credentialUsages: CredentialUsages[] = [ const credentialUsages: CredentialUsages[] = [
{ {
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter', credentialName: 'twitter',
credentialType: 'twitterOAuth1Api', credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter', nodeTypeName: 'n8n-nodes-base.twitter',
@ -141,6 +187,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
appName: 'Twitter', appName: 'Twitter',
credentials: [ credentials: [
{ {
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter', credentialName: 'twitter',
credentialType: 'twitterOAuth1Api', credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter', nodeTypeName: 'n8n-nodes-base.twitter',
@ -152,6 +199,54 @@ describe('SetupWorkflowFromTemplateView store', () => {
}); });
}); });
describe('credentialOverrides', () => {
beforeEach(() => {
setActivePinia(
createTestingPinia({
stubActions: false,
}),
);
});
it('returns an empty object if there are no credential overrides', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
expect(setupTemplateStore.credentialUsages.length).toBe(3);
expect(setupTemplateStore.credentialOverrides).toEqual({});
});
it('returns overrides for one node', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
credentialsStore.setCredentials([testData.credentialsTelegram1]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
setupTemplateStore.setSelectedCredentialId(
keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
testData.credentialsTelegram1.id,
);
expect(setupTemplateStore.credentialUsages.length).toBe(3);
expect(setupTemplateStore.credentialOverrides).toEqual({
'twitterOAuth1Api-twitter': {
id: testData.credentialsTelegram1.id,
name: testData.credentialsTelegram1.name,
},
});
});
});
describe('setInitialCredentialsSelection', () => { describe('setInitialCredentialsSelection', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia( setActivePinia(
@ -174,7 +269,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute // Execute
setupTemplateStore.setInitialCredentialSelection(); setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({}); expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
}); });
it("selects credential when there's only one", () => { it("selects credential when there's only one", () => {
@ -191,8 +286,8 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute // Execute
setupTemplateStore.setInitialCredentialSelection(); setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({ expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({
telegram_habot: 'YaSKdvEcT1TSFrrr1', [keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: 'YaSKdvEcT1TSFrrr1',
}); });
}); });
@ -213,7 +308,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute // Execute
setupTemplateStore.setInitialCredentialSelection(); setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({}); expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
}); });
test.each([ test.each([
@ -255,7 +350,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
setupTemplateStore.setInitialCredentialSelection(); setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.credentialUsages.length).toBe(1); expect(setupTemplateStore.credentialUsages.length).toBe(1);
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({}); expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
}); });
}); });
}); });

View file

@ -19,9 +19,13 @@ import type {
import type { Telemetry } from '@/plugins/telemetry'; import type { Telemetry } from '@/plugins/telemetry';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions'; import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms'; import type {
TemplateCredentialKey,
IWorkflowTemplateNodeWithCredentials,
} from '@/utils/templates/templateTransforms';
import { import {
hasNodeCredentials, hasNodeCredentials,
keyFromCredentialTypeAndName,
normalizeTemplateNodeCredentials, normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms'; } from '@/utils/templates/templateTransforms';
@ -37,6 +41,11 @@ export type RequiredCredentials = {
}; };
export type CredentialUsages = { export type CredentialUsages = {
/**
* Key is a combination of the credential name and the credential type name,
* e.g. "twitter-twitterOAuth1Api"
*/
key: TemplateCredentialKey;
credentialName: string; credentialName: string;
credentialType: string; credentialType: string;
nodeTypeName: string; nodeTypeName: string;
@ -65,30 +74,32 @@ export const getNodesRequiringCredentials = (
return template.workflow.nodes.filter(hasNodeCredentials); return template.workflow.nodes.filter(hasNodeCredentials);
}; };
export const groupNodeCredentialsByName = (nodes: IWorkflowTemplateNodeWithCredentials[]) => { export const groupNodeCredentialsByKey = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
const credentialsByName = new Map<string, CredentialUsages>(); const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages>();
for (const node of nodes) { for (const node of nodes) {
const normalizedCreds = normalizeTemplateNodeCredentials(node.credentials); const normalizedCreds = normalizeTemplateNodeCredentials(node.credentials);
for (const credentialType in normalizedCreds) { for (const credentialType in normalizedCreds) {
const credentialName = normalizedCreds[credentialType]; const credentialName = normalizedCreds[credentialType];
const key = keyFromCredentialTypeAndName(credentialType, credentialName);
let credentialUsages = credentialsByName.get(credentialName); let credentialUsages = credentialsByTypeName.get(key);
if (!credentialUsages) { if (!credentialUsages) {
credentialUsages = { credentialUsages = {
key,
nodeTypeName: node.type, nodeTypeName: node.type,
credentialName, credentialName,
credentialType, credentialType,
usedBy: [], usedBy: [],
}; };
credentialsByName.set(credentialName, credentialUsages); credentialsByTypeName.set(key, credentialUsages);
} }
credentialUsages.usedBy.push(node); credentialUsages.usedBy.push(node);
} }
} }
return credentialsByName; return credentialsByTypeName;
}; };
export const getAppCredentials = ( export const getAppCredentials = (
@ -154,8 +165,8 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
* Credentials user has selected from the UI. Map from credential * Credentials user has selected from the UI. Map from credential
* name in the template to the credential ID. * name in the template to the credential ID.
*/ */
const selectedCredentialIdByName = ref< const selectedCredentialIdByKey = ref<
Record<CredentialUsages['credentialName'], ICredentialsResponse['id']> Record<CredentialUsages['key'], ICredentialsResponse['id']>
>({}); >({});
//#endregion State //#endregion State
@ -185,12 +196,12 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName; return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
}; };
const credentialsByName = computed(() => { const credentialsByKey = computed(() => {
return groupNodeCredentialsByName(nodesRequiringCredentialsSorted.value); return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
}); });
const credentialUsages = computed(() => { const credentialUsages = computed(() => {
return Array.from(credentialsByName.value.values()); return Array.from(credentialsByKey.value.values());
}); });
const appCredentials = computed(() => { const appCredentials = computed(() => {
@ -198,20 +209,16 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
}); });
const credentialOverrides = computed(() => { const credentialOverrides = computed(() => {
const overrides: Record<string, INodeCredentialsDetails> = {}; const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
for (const credentialNameInTemplate of Object.keys(selectedCredentialIdByName.value)) {
const credentialId = selectedCredentialIdByName.value[credentialNameInTemplate];
if (!credentialId) {
continue;
}
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
const credential = credentialsStore.getCredentialById(credentialId); const credential = credentialsStore.getCredentialById(credentialId);
if (!credential) { if (!credential) {
continue; continue;
} }
overrides[credentialNameInTemplate] = { // Object.entries fails to give the more accurate key type
overrides[key as TemplateCredentialKey] = {
id: credentialId, id: credentialId,
name: credential.name, name: credential.name,
}; };
@ -221,7 +228,7 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
}); });
const numCredentialsLeft = computed(() => { const numCredentialsLeft = computed(() => {
return credentialUsages.value.length - Object.keys(selectedCredentialIdByName.value).length; return credentialUsages.value.length - Object.keys(selectedCredentialIdByKey.value).length;
}); });
//#endregion Getters //#endregion Getters
@ -255,7 +262,7 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
const availableCreds = credentialsStore.getCredentialsByType(credUsage.credentialType); const availableCreds = credentialsStore.getCredentialsByType(credUsage.credentialType);
if (availableCreds.length === 1) { if (availableCreds.length === 1) {
selectedCredentialIdByName.value[credUsage.credentialName] = availableCreds[0].id; selectedCredentialIdByKey.value[credUsage.key] = availableCreds[0].id;
} }
} }
}; };
@ -279,7 +286,7 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
const init = async () => { const init = async () => {
isLoading.value = true; isLoading.value = true;
try { try {
selectedCredentialIdByName.value = {}; selectedCredentialIdByKey.value = {};
await Promise.all([ await Promise.all([
credentialsStore.fetchAllCredentials(), credentialsStore.fetchAllCredentials(),
@ -349,26 +356,27 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
} }
}; };
const setSelectedCredentialId = (credentialName: string, credentialId: string) => { const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
selectedCredentialIdByName.value[credentialName] = credentialId; selectedCredentialIdByKey.value[credentialKey] = credentialId;
}; };
const unsetSelectedCredential = (credentialName: string) => { const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
delete selectedCredentialIdByName.value[credentialName]; delete selectedCredentialIdByKey.value[credentialKey];
}; };
//#endregion Actions //#endregion Actions
return { return {
credentialsByName, credentialsByKey,
isLoading, isLoading,
isSaving, isSaving,
appCredentials, appCredentials,
nodesRequiringCredentialsSorted, nodesRequiringCredentialsSorted,
template, template,
credentialUsages, credentialUsages,
selectedCredentialIdByName, selectedCredentialIdByKey,
numCredentialsLeft, numCredentialsLeft,
credentialOverrides,
createWorkflow, createWorkflow,
skipSetup, skipSetup,
init, init,