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

View file

@ -5,6 +5,24 @@ import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode &
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
*/
@ -32,16 +50,16 @@ export const normalizeTemplateNodeCredentials = (
*
* @example
* const nodeCredentials = { twitterOAuth1Api: "twitter" };
* const toReplaceByType = { twitter: {
* const toReplaceByKey = { 'twitterOAuth1Api-twitter': {
* id: "BrEOZ5Cje6VYh9Pc",
* name: "X OAuth account"
* }};
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByType);
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByKey);
* // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } }
*/
export const replaceTemplateNodeCredentials = (
nodeCredentials: IWorkflowTemplateNodeCredentials,
toReplaceByName: Record<string, INodeCredentialsDetails>,
toReplaceByKey: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => {
if (!nodeCredentials) {
return undefined;
@ -51,7 +69,8 @@ export const replaceTemplateNodeCredentials = (
const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials);
for (const credentialType in normalizedCredentials) {
const credentialNameInTemplate = normalizedCredentials[credentialType];
const toReplaceWith = toReplaceByName[credentialNameInTemplate];
const key = keyFromCredentialTypeAndName(credentialType, credentialNameInTemplate);
const toReplaceWith = toReplaceByKey[key];
if (toReplaceWith) {
newNodeCredentials[credentialType] = toReplaceWith;
}
@ -66,7 +85,7 @@ export const replaceTemplateNodeCredentials = (
*/
export const replaceAllTemplateNodeCredentials = (
nodes: IWorkflowTemplateNode[],
toReplaceWith: Record<string, INodeCredentialsDetails>,
toReplaceWith: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => {
return nodes.map((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">
import type { PropType } from 'vue';
import { computed } from 'vue';
import N8nHeading from 'n8n-design-system/components/N8nHeading';
import NodeIcon from '@/components/NodeIcon.vue';
import CredentialPicker from '@/components/CredentialPicker/CredentialPicker.vue';
import IconSuccess from './IconSuccess.vue';
import { assert } from '@/utils/assert';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import { formatList } from '@/utils/formatters/listFormatter';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import type { IWorkflowTemplateNode } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
@ -18,8 +19,8 @@ const props = defineProps({
type: Number,
required: true,
},
credentialName: {
type: String,
credentials: {
type: Object as PropType<CredentialUsages>,
required: true,
},
});
@ -31,34 +32,26 @@ const i18n = useI18n();
//#region Computed
const credentials = computed(() => {
const credential = setupTemplateStore.credentialsByName.get(props.credentialName);
assert(credential);
return credential;
});
const node = computed(() => credentials.value.usedBy[0]);
const node = computed(() => props.credentials.usedBy[0]);
const nodeType = computed(() =>
nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
);
const credentialType = computed(() => credentials.value.credentialType);
const appName = computed(() =>
nodeType.value ? getAppNameFromNodeName(nodeType.value.displayName) : node.value.type,
);
const nodeNames = computed(() => {
const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`;
return formatList(credentials.value.usedBy, {
return formatList(props.credentials.usedBy, {
formatFn: formatNodeName,
i18n,
});
});
const selectedCredentialId = computed(
() => setupTemplateStore.selectedCredentialIdByName[props.credentialName],
() => setupTemplateStore.selectedCredentialIdByKey[props.credentials.key],
);
//#endregion Computed
@ -66,11 +59,11 @@ const selectedCredentialId = computed(
//#region Methods
const onCredentialSelected = (credentialId: string) => {
setupTemplateStore.setSelectedCredentialId(props.credentialName, credentialId);
setupTemplateStore.setSelectedCredentialId(props.credentials.key, credentialId);
};
const onCredentialDeselected = () => {
setupTemplateStore.unsetSelectedCredential(props.credentialName);
setupTemplateStore.unsetSelectedCredential(props.credentials.key);
};
//#endregion Methods
@ -101,7 +94,7 @@ const onCredentialDeselected = () => {
<CredentialPicker
:class="$style.credentialPicker"
:app-name="appName"
:credentialType="credentialType"
:credentialType="props.credentials.credentialType"
:selectedCredentialId="selectedCredentialId"
@credential-selected="onCredentialSelected"
@credential-deselected="onCredentialDeselected"

View file

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

View file

@ -1,19 +1,24 @@
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 {
getAppCredentials,
getAppsRequiringCredentials,
groupNodeCredentialsByName,
useSetupTemplateStore,
groupNodeCredentialsByKey,
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { setActivePinia } from 'pinia';
import * as testData from './setupTemplate.store.testData';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '@/stores/credentials.store';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
const objToMap = <T>(obj: Record<string, T>) => {
return new Map<string, T>(Object.entries(obj));
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map(Object.entries(obj)) as Map<TKey, T>;
};
describe('SetupWorkflowFromTemplateView store', () => {
@ -60,27 +65,30 @@ describe('SetupWorkflowFromTemplateView store', () => {
},
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
describe('groupNodeCredentialsByName', () => {
describe('groupNodeCredentialsByTypeAndName', () => {
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', () => {
expect(groupNodeCredentialsByName(Object.values(nodesByName))).toEqual(
it('returns credentials grouped by type and name', () => {
expect(groupNodeCredentialsByKey(Object.values(nodesByName))).toEqual(
objToMap({
twitter: {
'twitterOAuth1Api-twitter': {
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
telegram: {
'telegramApi-telegram': {
key: 'telegramApi-telegram',
credentialName: 'telegram',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [nodesByName.Telegram],
},
shopify: {
'shopifyApi-shopify': {
key: 'shopifyApi-shopify',
credentialName: 'shopify',
credentialType: 'shopifyApi',
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', () => {
@ -98,8 +142,9 @@ describe('SetupWorkflowFromTemplateView store', () => {
});
it('returns an array of apps requiring credentials', () => {
const credentialUsages: Map<string, CredentialUsages> = objToMap({
twitter: {
const credentialUsages = objToMap<TemplateCredentialKey, CredentialUsages>({
[keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter')]: {
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
@ -127,6 +172,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
it('returns an array of apps requiring credentials', () => {
const credentialUsages: CredentialUsages[] = [
{
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
@ -141,6 +187,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
appName: 'Twitter',
credentials: [
{
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
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', () => {
beforeEach(() => {
setActivePinia(
@ -174,7 +269,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute
setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({});
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
});
it("selects credential when there's only one", () => {
@ -191,8 +286,8 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute
setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({
telegram_habot: 'YaSKdvEcT1TSFrrr1',
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({
[keyFromCredentialTypeAndName('telegramApi', 'telegram_habot')]: 'YaSKdvEcT1TSFrrr1',
});
});
@ -213,7 +308,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
// Execute
setupTemplateStore.setInitialCredentialSelection();
expect(setupTemplateStore.selectedCredentialIdByName).toEqual({});
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
});
test.each([
@ -255,7 +350,7 @@ describe('SetupWorkflowFromTemplateView store', () => {
setupTemplateStore.setInitialCredentialSelection();
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 { VIEWS } from '@/constants';
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
import type {
TemplateCredentialKey,
IWorkflowTemplateNodeWithCredentials,
} from '@/utils/templates/templateTransforms';
import {
hasNodeCredentials,
keyFromCredentialTypeAndName,
normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
@ -37,6 +41,11 @@ export type RequiredCredentials = {
};
export type CredentialUsages = {
/**
* Key is a combination of the credential name and the credential type name,
* e.g. "twitter-twitterOAuth1Api"
*/
key: TemplateCredentialKey;
credentialName: string;
credentialType: string;
nodeTypeName: string;
@ -65,30 +74,32 @@ export const getNodesRequiringCredentials = (
return template.workflow.nodes.filter(hasNodeCredentials);
};
export const groupNodeCredentialsByName = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
const credentialsByName = new Map<string, CredentialUsages>();
export const groupNodeCredentialsByKey = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages>();
for (const node of nodes) {
const normalizedCreds = normalizeTemplateNodeCredentials(node.credentials);
for (const credentialType in normalizedCreds) {
const credentialName = normalizedCreds[credentialType];
const key = keyFromCredentialTypeAndName(credentialType, credentialName);
let credentialUsages = credentialsByName.get(credentialName);
let credentialUsages = credentialsByTypeName.get(key);
if (!credentialUsages) {
credentialUsages = {
key,
nodeTypeName: node.type,
credentialName,
credentialType,
usedBy: [],
};
credentialsByName.set(credentialName, credentialUsages);
credentialsByTypeName.set(key, credentialUsages);
}
credentialUsages.usedBy.push(node);
}
}
return credentialsByName;
return credentialsByTypeName;
};
export const getAppCredentials = (
@ -154,8 +165,8 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
* Credentials user has selected from the UI. Map from credential
* name in the template to the credential ID.
*/
const selectedCredentialIdByName = ref<
Record<CredentialUsages['credentialName'], ICredentialsResponse['id']>
const selectedCredentialIdByKey = ref<
Record<CredentialUsages['key'], ICredentialsResponse['id']>
>({});
//#endregion State
@ -185,12 +196,12 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
};
const credentialsByName = computed(() => {
return groupNodeCredentialsByName(nodesRequiringCredentialsSorted.value);
const credentialsByKey = computed(() => {
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
});
const credentialUsages = computed(() => {
return Array.from(credentialsByName.value.values());
return Array.from(credentialsByKey.value.values());
});
const appCredentials = computed(() => {
@ -198,20 +209,16 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
});
const credentialOverrides = computed(() => {
const overrides: Record<string, INodeCredentialsDetails> = {};
for (const credentialNameInTemplate of Object.keys(selectedCredentialIdByName.value)) {
const credentialId = selectedCredentialIdByName.value[credentialNameInTemplate];
if (!credentialId) {
continue;
}
const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
const credential = credentialsStore.getCredentialById(credentialId);
if (!credential) {
continue;
}
overrides[credentialNameInTemplate] = {
// Object.entries fails to give the more accurate key type
overrides[key as TemplateCredentialKey] = {
id: credentialId,
name: credential.name,
};
@ -221,7 +228,7 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
});
const numCredentialsLeft = computed(() => {
return credentialUsages.value.length - Object.keys(selectedCredentialIdByName.value).length;
return credentialUsages.value.length - Object.keys(selectedCredentialIdByKey.value).length;
});
//#endregion Getters
@ -255,7 +262,7 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
const availableCreds = credentialsStore.getCredentialsByType(credUsage.credentialType);
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 () => {
isLoading.value = true;
try {
selectedCredentialIdByName.value = {};
selectedCredentialIdByKey.value = {};
await Promise.all([
credentialsStore.fetchAllCredentials(),
@ -349,26 +356,27 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
}
};
const setSelectedCredentialId = (credentialName: string, credentialId: string) => {
selectedCredentialIdByName.value[credentialName] = credentialId;
const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
selectedCredentialIdByKey.value[credentialKey] = credentialId;
};
const unsetSelectedCredential = (credentialName: string) => {
delete selectedCredentialIdByName.value[credentialName];
const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
delete selectedCredentialIdByKey.value[credentialKey];
};
//#endregion Actions
return {
credentialsByName,
credentialsByKey,
isLoading,
isSaving,
appCredentials,
nodesRequiringCredentialsSorted,
template,
credentialUsages,
selectedCredentialIdByName,
selectedCredentialIdByKey,
numCredentialsLeft,
credentialOverrides,
createWorkflow,
skipSetup,
init,