fix: Fix template credential setup for nodes that dont have credentials (#8208)

Fix template credential setup for templates whose workflow includes
nodes that require credentials but the workflow definition does not have
them defined. Like for example
https://n8n.io/workflows/1344-save-email-attachments-to-nextcloud/
This commit is contained in:
Tomi Turtiainen 2024-01-04 10:21:36 +02:00 committed by GitHub
parent 4186884740
commit cd3f5b5b1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 5596 additions and 348 deletions

View file

@ -1,4 +1,3 @@
import { CredentialsModal, MessageBox } from '../pages/modals';
import {
clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage,
@ -9,8 +8,6 @@ import { TemplateWorkflowPage } from '../pages/template-workflow';
import { WorkflowPage } from '../pages/workflow';
const templateWorkflowPage = new TemplateWorkflowPage();
const credentialsModal = new CredentialsModal();
const messageBox = new MessageBox();
const workflowPage = new WorkflowPage();
const testTemplate = templateCredentialsSetupPage.testData.simpleTemplate;
@ -95,24 +92,48 @@ describe('Template credentials setup', () => {
// Continue button should be disabled if no credentials are created
templateCredentialsSetupPage.getters.continueButton().should('be.disabled');
templateCredentialsSetupPage.getters.createAppCredentialsButton('Shopify').click();
credentialsModal.getters.editCredentialModal().find('input:first()').type('test');
credentialsModal.actions.save(false);
credentialsModal.actions.close();
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
// Continue button should be enabled if at least one has been created
templateCredentialsSetupPage.getters.continueButton().should('be.enabled');
templateCredentialsSetupPage.getters.createAppCredentialsButton('X (Formerly Twitter)').click();
credentialsModal.getters.editCredentialModal().find('input:first()').type('test');
credentialsModal.actions.save(false);
credentialsModal.actions.close();
messageBox.actions.cancel();
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
templateCredentialsSetupPage.getters.createAppCredentialsButton('Telegram').click();
credentialsModal.getters.editCredentialModal().find('input:first()').type('test');
credentialsModal.actions.save(false);
credentialsModal.actions.close();
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
templateCredentialsSetupPage.getters.continueButton().should('be.enabled');
templateCredentialsSetupPage.getters.continueButton().click();
cy.wait('@createWorkflow');
workflowPage.getters.canvasNodes().should('have.length', 3);
});
it('should work with a template that has no credentials (ADO-1603)', () => {
const templateWithoutCreds = templateCredentialsSetupPage.testData.templateWithoutCredentials;
cy.intercept('GET', `https://api.n8n.io/api/templates/workflows/${templateWithoutCreds.id}`, {
fixture: templateWithoutCreds.fixture,
});
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(templateWithoutCreds.id);
const expectedAppNames = ['1. Email (IMAP)', '2. Nextcloud'];
const expectedAppDescriptions = [
'The credential you select will be used in the IMAP Email node of the workflow template.',
'The credential you select will be used in the Nextcloud node of the workflow template.',
];
templateCredentialsSetupPage.getters.appCredentialSteps().each(($el, index) => {
templateCredentialsSetupPage.getters
.stepHeading($el)
.should('have.text', expectedAppNames[index]);
templateCredentialsSetupPage.getters
.stepDescription($el)
.should('have.text', expectedAppDescriptions[index]);
});
templateCredentialsSetupPage.getters.continueButton().should('be.disabled');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
cy.intercept('POST', '/rest/workflows').as('createWorkflow');
templateCredentialsSetupPage.getters.continueButton().should('be.enabled');

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,5 @@
import { CredentialsModal, MessageBox } from './modals';
export type TemplateTestData = {
id: number;
fixture: string;
@ -8,8 +10,15 @@ export const testData = {
id: 1205,
fixture: 'Test_Template_1.json',
},
templateWithoutCredentials: {
id: 1344,
fixture: 'Test_Template_2.json',
},
};
const credentialsModal = new CredentialsModal();
const messageBox = new MessageBox();
export const getters = {
continueButton: () => cy.getByTestId('continue-button'),
skipLink: () => cy.get('a:contains("Skip")'),
@ -33,3 +42,23 @@ export const enableTemplateCredentialSetupFeatureFlag = () => {
win.featureFlags.override('016_template_credential_setup', true);
});
};
/**
* Fills in dummy credentials for the given app name.
*/
export const fillInDummyCredentialsForApp = (appName: string) => {
getters.createAppCredentialsButton(appName).click();
credentialsModal.getters.editCredentialModal().find('input:first()').type('test');
credentialsModal.actions.save(false);
credentialsModal.actions.close();
};
/**
* Fills in dummy credentials for the given app name. Assumes
* that a confirmation message box will be shown, which will be
* handled.
*/
export const fillInDummyCredentialsForAppWithConfirm = (appName: string) => {
fillInDummyCredentialsForApp(appName);
messageBox.actions.cancel();
};

View file

@ -1,6 +1,9 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { type INodeTypeDescription } from 'n8n-workflow';
import type { NodeTypesByTypeNameAndVersion } from '@/Interface';
import { DEFAULT_NODETYPE_VERSION } from '@/constants';
import type { NodeTypesStore } from '@/stores/nodeTypes.store';
export type NodeTypeProvider = Pick<NodeTypesStore, 'getNodeType'>;
export function getNodeVersions(nodeType: INodeTypeDescription) {
return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version];

View file

@ -0,0 +1,33 @@
import type { INodeUi } from '@/Interface';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import type { INodeCredentialDescription } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
/**
* Returns the credentials that are displayable for the given node.
*/
export function getNodeTypeDisplayableCredentials(
nodeTypeProvider: NodeTypeProvider,
node: Pick<INodeUi, 'parameters' | 'type' | 'typeVersion'>,
): INodeCredentialDescription[] {
const nodeType = nodeTypeProvider.getNodeType(node.type, node.typeVersion);
if (!nodeType?.credentials) {
return [];
}
const nodeTypeCreds = nodeType.credentials;
// We must populate the node's parameters with the default values
// before we can check which credentials are available, because
// credentials can have conditional requirements that depend on
// node parameters.
const nodeParameters =
NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, true, false, node) ??
node.parameters;
const displayableCredentials = nodeTypeCreds.filter((credentialTypeDescription) => {
return NodeHelpers.displayParameter(nodeParameters, credentialTypeDescription, node);
});
return displayableCredentials;
}

View file

@ -7,6 +7,9 @@ import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
describe('templateTransforms', () => {
describe('replaceAllTemplateNodeCredentials', () => {
it('should replace credentials of nodes that have credentials', () => {
const nodeTypeProvider = {
getNodeType: vitest.fn(),
};
const node = newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
credentials: {
@ -21,7 +24,11 @@ describe('templateTransforms', () => {
},
};
const [replacedNode] = replaceAllTemplateNodeCredentials([node], toReplaceWith);
const [replacedNode] = replaceAllTemplateNodeCredentials(
nodeTypeProvider,
[node],
toReplaceWith,
);
expect(replacedNode.credentials).toEqual({
twitterOAuth1Api: { id: 'new1', name: 'Twitter creds' },
@ -29,6 +36,9 @@ describe('templateTransforms', () => {
});
it('should not replace credentials of nodes that do not have credentials', () => {
const nodeTypeProvider = {
getNodeType: vitest.fn(),
};
const node = newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
});
@ -39,7 +49,11 @@ describe('templateTransforms', () => {
},
};
const [replacedNode] = replaceAllTemplateNodeCredentials([node], toReplaceWith);
const [replacedNode] = replaceAllTemplateNodeCredentials(
nodeTypeProvider,
[node],
toReplaceWith,
);
expect(replacedNode.credentials).toBeUndefined();
});

View file

@ -5,6 +5,7 @@ import type { useRootStore } from '@/stores/n8nRoot.store';
import type { PosthogStore } from '@/stores/posthog.store';
import type { useWorkflowsStore } from '@/stores/workflows.store';
import { getFixedNodesList } from '@/utils/nodeViewUtils';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
import type { INodeCredentialsDetails } from 'n8n-workflow';
@ -13,14 +14,18 @@ import type { RouteLocationRaw, Router } from 'vue-router';
/**
* Creates a new workflow from a template
*/
export async function createWorkflowFromTemplate(
template: IWorkflowTemplate,
credentialOverrides: Record<TemplateCredentialKey, INodeCredentialsDetails>,
rootStore: ReturnType<typeof useRootStore>,
workflowsStore: ReturnType<typeof useWorkflowsStore>,
) {
export async function createWorkflowFromTemplate(opts: {
template: IWorkflowTemplate;
credentialOverrides: Record<TemplateCredentialKey, INodeCredentialsDetails>;
rootStore: ReturnType<typeof useRootStore>;
workflowsStore: ReturnType<typeof useWorkflowsStore>;
nodeTypeProvider: NodeTypeProvider;
}) {
const { credentialOverrides, nodeTypeProvider, rootStore, template, workflowsStore } = opts;
const workflowData = await getNewWorkflow(rootStore.getRestApiContext, template.name);
const nodesWithCreds = replaceAllTemplateNodeCredentials(
nodeTypeProvider,
template.workflow.nodes,
credentialOverrides,
);

View file

@ -1,4 +1,6 @@
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
@ -23,14 +25,6 @@ export const keyFromCredentialTypeAndName = (
credentialName: string,
): TemplateCredentialKey => `${credentialTypeName}-${credentialName}` as TemplateCredentialKey;
/**
* Checks if a template workflow node has credentials defined
*/
export const hasNodeCredentials = (
node: IWorkflowTemplateNode,
): node is IWorkflowTemplateNodeWithCredentials =>
!!node.credentials && Object.keys(node.credentials).length > 0;
/**
* Normalizes the credentials of a template node. Templates created with
* different versions of n8n may have different credential formats.
@ -57,26 +51,49 @@ export const normalizeTemplateNodeCredentials = (
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByKey);
* // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } }
*/
export const replaceTemplateNodeCredentials = (
nodeCredentials: IWorkflowTemplateNodeCredentials,
export const getReplacedTemplateNodeCredentials = (
nodeCredentials: IWorkflowTemplateNodeCredentials | undefined,
toReplaceByKey: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => {
if (!nodeCredentials) {
return undefined;
}
const newNodeCredentials: INodeCredentials = {};
const replacedNodeCredentials: INodeCredentials = {};
const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials);
for (const credentialType in normalizedCredentials) {
const credentialNameInTemplate = normalizedCredentials[credentialType];
const key = keyFromCredentialTypeAndName(credentialType, credentialNameInTemplate);
const toReplaceWith = toReplaceByKey[key];
if (toReplaceWith) {
newNodeCredentials[credentialType] = toReplaceWith;
replacedNodeCredentials[credentialType] = toReplaceWith;
}
}
return newNodeCredentials;
return replacedNodeCredentials;
};
/**
* Returns credentials for the given node that are missing from it
* but are present in the given replacements
*/
export const getMissingTemplateNodeCredentials = (
nodeTypeProvider: NodeTypeProvider,
node: IWorkflowTemplateNode,
replacementsByKey: Record<TemplateCredentialKey, INodeCredentialsDetails>,
): INodeCredentials => {
const nodeCredentialsToAdd: INodeCredentials = {};
const usableCredentials = getNodeTypeDisplayableCredentials(nodeTypeProvider, node);
for (const usableCred of usableCredentials) {
const credentialKey = keyFromCredentialTypeAndName(usableCred.name, '');
if (replacementsByKey[credentialKey]) {
nodeCredentialsToAdd[usableCred.name] = replacementsByKey[credentialKey];
}
}
return nodeCredentialsToAdd;
};
/**
@ -84,17 +101,22 @@ export const replaceTemplateNodeCredentials = (
* replacements
*/
export const replaceAllTemplateNodeCredentials = (
nodeTypeProvider: NodeTypeProvider,
nodes: IWorkflowTemplateNode[],
toReplaceWith: Record<TemplateCredentialKey, INodeCredentialsDetails>,
) => {
return nodes.map((node) => {
if (hasNodeCredentials(node)) {
return {
...node,
credentials: replaceTemplateNodeCredentials(node.credentials, toReplaceWith),
};
}
const replacedCredentials = getReplacedTemplateNodeCredentials(node.credentials, toReplaceWith);
const newCredentials = getMissingTemplateNodeCredentials(nodeTypeProvider, node, toReplaceWith);
return node;
const credentials = {
...replacedCredentials,
...newCredentials,
};
return {
...node,
credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
};
});
};

View file

@ -0,0 +1,36 @@
/**
* Credential type test data
*/
import type { ICredentialType } from 'n8n-workflow';
export const newCredentialType = (name: string): ICredentialType => ({
name,
displayName: name,
documentationUrl: name,
properties: [],
});
export const credentialTypeTelegram = {
name: 'telegramApi',
displayName: 'Telegram API',
documentationUrl: 'telegram',
properties: [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: {
password: true,
},
default: '',
description:
'Chat with the <a href="https://telegram.me/botfather">bot father</a> to obtain the access token',
},
],
test: {
request: {
baseURL: '=https://api.telegram.org/bot{{$credentials.accessToken}}',
url: '/getMe',
},
},
} satisfies ICredentialType;

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,7 @@ import * as testData from './setupTemplate.store.testData';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '@/stores/credentials.store';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map(Object.entries(obj)) as Map<TKey, T>;
@ -36,33 +37,6 @@ describe('SetupWorkflowFromTemplateView store', () => {
},
typeVersion: 1,
},
Telegram: {
name: 'Telegram',
type: 'n8n-nodes-base.telegram',
position: [720, -20],
parameters: {
text: '=Hey there, my design is now on a new product!\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}})',
chatId: '123456',
additionalFields: {},
},
credentials: {
telegramApi: 'telegram',
},
typeVersion: 1,
},
shopify: {
name: 'shopify',
type: 'n8n-nodes-base.shopifyTrigger',
position: [540, -110],
webhookId: '2a7e0e50-8f09-4a2b-bf54-a849a6ac4fe0',
parameters: {
topic: 'products/create',
},
credentials: {
shopifyApi: 'shopify',
},
typeVersion: 1,
},
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
describe('groupNodeCredentialsByTypeAndName', () => {
@ -71,7 +45,14 @@ describe('SetupWorkflowFromTemplateView store', () => {
});
it('returns credentials grouped by type and name', () => {
expect(groupNodeCredentialsByKey(Object.values(nodesByName))).toEqual(
expect(
groupNodeCredentialsByKey([
{
node: nodesByName.Twitter,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-twitter': {
key: 'twitterOAuth1Api-twitter',
@ -80,20 +61,6 @@ describe('SetupWorkflowFromTemplateView store', () => {
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
'telegramApi-telegram': {
key: 'telegramApi-telegram',
credentialName: 'telegram',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [nodesByName.Telegram],
},
'shopifyApi-shopify': {
key: 'shopifyApi-shopify',
credentialName: 'shopify',
credentialType: 'shopifyApi',
nodeTypeName: 'n8n-nodes-base.shopifyTrigger',
usedBy: [nodesByName.shopify],
},
}),
);
});
@ -114,7 +81,18 @@ describe('SetupWorkflowFromTemplateView store', () => {
}) as IWorkflowTemplateNodeWithCredentials,
];
expect(groupNodeCredentialsByKey([node1, node2])).toEqual(
expect(
groupNodeCredentialsByKey([
{
node: node1,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
{
node: node2,
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-credential': {
key: 'twitterOAuth1Api-credential',
@ -206,32 +184,35 @@ describe('SetupWorkflowFromTemplateView store', () => {
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 nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeTelegramV1,
testData.nodeTypeTwitterV1,
testData.nodeTypeShopifyTriggerV1,
]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
});
it('should return an empty object if there are no credential overrides', () => {
// Setup
const setupTemplateStore = useSetupTemplateStore();
expect(setupTemplateStore.credentialUsages.length).toBe(3);
expect(setupTemplateStore.credentialOverrides).toEqual({});
});
it('returns overrides for one node', () => {
it('should return 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,
@ -254,17 +235,25 @@ describe('SetupWorkflowFromTemplateView store', () => {
stubActions: false,
}),
);
});
it("selects no credentials when there isn't any available", () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeTelegramV1,
testData.nodeTypeTwitterV1,
testData.nodeTypeShopifyTriggerV1,
testData.nodeTypeHttpRequestV1,
]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
});
it("should select no credentials when there isn't any available", () => {
// Setup
const setupTemplateStore = useSetupTemplateStore();
// Execute
setupTemplateStore.setInitialCredentialSelection();
@ -272,16 +261,12 @@ describe('SetupWorkflowFromTemplateView store', () => {
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
});
it("selects credential when there's only one", () => {
it("should select credential when there's only one", () => {
// 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());
// Execute
setupTemplateStore.setInitialCredentialSelection();
@ -291,19 +276,15 @@ describe('SetupWorkflowFromTemplateView store', () => {
});
});
it('selects no credentials when there are more than 1 available', () => {
it('should select no credentials when there are more than 1 available', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
credentialsStore.setCredentials([
testData.credentialsTelegram1,
testData.credentialsTelegram2,
]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
// Execute
setupTemplateStore.setInitialCredentialSelection();
@ -312,14 +293,14 @@ describe('SetupWorkflowFromTemplateView store', () => {
});
test.each([
['httpBasicAuth'],
['httpCustomAuth'],
['httpDigestAuth'],
['httpHeaderAuth'],
['oAuth1Api'],
['oAuth2Api'],
['httpQueryAuth'],
])('does not auto-select credentials for %s', (credentialType) => {
['httpBasicAuth', 'basicAuth'],
['httpCustomAuth', 'basicAuth'],
['httpDigestAuth', 'digestAuth'],
['httpHeaderAuth', 'headerAuth'],
['oAuth1Api', 'oAuth1'],
['oAuth2Api', 'oAuth2'],
['httpQueryAuth', 'queryAuth'],
])('should not auto-select credentials for %s', (credentialType, auth) => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.newCredentialType(credentialType)]);
@ -338,7 +319,9 @@ describe('SetupWorkflowFromTemplateView store', () => {
credentials: {
[credentialType]: 'Test',
},
parameters: {},
parameters: {
authentication: auth,
},
position: [250, 300],
});
templatesStore.addWorkflows([workflow]);
@ -353,4 +336,84 @@ describe('SetupWorkflowFromTemplateView store', () => {
expect(setupTemplateStore.selectedCredentialIdByKey).toEqual({});
});
});
describe("With template that has nodes requiring credentials but workflow doesn't have them", () => {
beforeEach(() => {
setActivePinia(
createTestingPinia({
stubActions: false,
}),
);
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullSaveEmailAttachmentsToNextCloudTemplate]);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeReadImapV1,
testData.nodeTypeReadImapV2,
testData.nodeTypeNextCloudV1,
]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(
testData.fullSaveEmailAttachmentsToNextCloudTemplate.id.toString(),
);
});
const templateImapNode = testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[0];
const templateNextcloudNode =
testData.fullSaveEmailAttachmentsToNextCloudTemplate.workflow.nodes[1];
it('should return correct credential usages', () => {
const setupTemplateStore = useSetupTemplateStore();
expect(setupTemplateStore.credentialUsages).toEqual([
{
credentialName: '',
credentialType: 'imap',
key: 'imap-',
nodeTypeName: 'n8n-nodes-base.emailReadImap',
usedBy: [templateImapNode],
},
{
credentialName: '',
credentialType: 'nextCloudApi',
key: 'nextCloudApi-',
nodeTypeName: 'n8n-nodes-base.nextCloud',
usedBy: [templateNextcloudNode],
},
]);
});
it('should return correct app credentials', () => {
const setupTemplateStore = useSetupTemplateStore();
expect(setupTemplateStore.appCredentials).toEqual([
{
appName: 'Email (IMAP)',
credentials: [
{
credentialName: '',
credentialType: 'imap',
key: 'imap-',
nodeTypeName: 'n8n-nodes-base.emailReadImap',
usedBy: [templateImapNode],
},
],
},
{
appName: 'Nextcloud',
credentials: [
{
credentialName: '',
credentialType: 'nextCloudApi',
key: 'nextCloudApi-',
nodeTypeName: 'n8n-nodes-base.nextCloud',
usedBy: [templateNextcloudNode],
},
],
},
]);
});
});
});

View file

@ -4,7 +4,6 @@ import type {
ITemplatesWorkflowFull,
IWorkflowTemplateNode,
} from '@/Interface';
import type { ICredentialType } from 'n8n-workflow';
export const newFullOneNodeTemplate = (node: IWorkflowTemplateNode): ITemplatesWorkflowFull => ({
full: true,
@ -45,183 +44,6 @@ export const newFullOneNodeTemplate = (node: IWorkflowTemplateNode): ITemplatesW
},
});
export const fullShopifyTelegramTwitterTemplate: ITemplatesWorkflowFull = {
full: true,
id: 1205,
name: 'Promote new Shopify products on Twitter and Telegram',
totalViews: 485,
createdAt: '2021-08-24T10:40:50.007Z',
description:
'This workflow automatically promotes your new Shopify products on Twitter and Telegram. This workflow is also featured in the blog post [*6 e-commerce workflows to power up your Shopify store*](https://n8n.io/blog/no-code-ecommerce-workflow-automations/#promote-your-new-products-on-social-media).\n\n## Prerequisites\n\n- A Shopify account and [credentials](https://docs.n8n.io/integrations/credentials/shopify/)\n- A Twitter account and [credentials](https://docs.n8n.io/integrations/credentials/twitter/)\n- A Telegram account and [credentials](https://docs.n8n.io/integrations/credentials/telegram/) for the channel you want to send messages to.\n\n## Nodes\n\n- [Shopify Trigger node](https://docs.n8n.io/integrations/trigger-nodes/n8n-nodes-base.shopifytrigger/) triggers the workflow when you create a new product in Shopify.\n- [Twitter node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.twitter/) posts a tweet with the text "Hey there, my design is now on a new product! Visit my {shop name} to get this cool {product title} (and check out more {product type})".\n- [Telegram node](https://docs.n8n.io/integrations/nodes/n8n-nodes-base.telegram/) posts a message with the same text as above in a Telegram channel.',
workflow: {
nodes: [
{
name: 'Twitter',
type: 'n8n-nodes-base.twitter',
position: [720, -220],
parameters: {
text: '=Hey there, my design is now on a new product ✨\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}}) 🛍️',
additionalFields: {},
},
credentials: {
twitterOAuth1Api: 'twitter',
},
typeVersion: 1,
},
{
name: 'Telegram',
type: 'n8n-nodes-base.telegram',
position: [720, -20],
parameters: {
text: '=Hey there, my design is now on a new product!\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}})',
chatId: '123456',
additionalFields: {},
},
credentials: {
telegramApi: 'telegram_habot',
},
typeVersion: 1,
},
{
name: 'product created',
type: 'n8n-nodes-base.shopifyTrigger',
position: [540, -110],
webhookId: '2a7e0e50-8f09-4a2b-bf54-a849a6ac4fe0',
parameters: {
topic: 'products/create',
},
credentials: {
shopifyApi: 'shopify_nodeqa',
},
typeVersion: 1,
},
],
connections: {
'product created': {
main: [
[
{
node: 'Twitter',
type: 'main',
index: 0,
},
{
node: 'Telegram',
type: 'main',
index: 0,
},
],
],
},
},
},
workflowInfo: {
nodeCount: 3,
nodeTypes: {
'n8n-nodes-base.twitter': {
count: 1,
},
'n8n-nodes-base.telegram': {
count: 1,
},
'n8n-nodes-base.shopifyTrigger': {
count: 1,
},
},
},
user: {
username: 'lorenanda',
},
nodes: [
{
id: 49,
icon: 'file:telegram.svg',
name: 'n8n-nodes-base.telegram',
defaults: {
name: 'Telegram',
},
iconData: {
type: 'file',
fileBuffer:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNjYgNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTAgMzJjMCAxNy42NzMgMTQuMzI3IDMyIDMyIDMyczMyLTE0LjMyNyAzMi0zMlM0OS42NzMgMCAzMiAwIDAgMTQuMzI3IDAgMzIiIGZpbGw9IiMzN2FlZTIiLz48cGF0aCBkPSJNMjEuNjYxIDM0LjMzOGwzLjc5NyAxMC41MDhzLjQ3NS45ODMuOTgzLjk4MyA4LjA2OC03Ljg2NCA4LjA2OC03Ljg2NGw4LjQwNy0xNi4yMzctMjEuMTE5IDkuODk4eiIgZmlsbD0iI2M4ZGFlYSIvPjxwYXRoIGQ9Ik0yNi42OTUgMzcuMDM0bC0uNzI5IDcuNzQ2cy0uMzA1IDIuMzczIDIuMDY4IDBsNC42NDQtNC4yMDMiIGZpbGw9IiNhOWM2ZDgiLz48cGF0aCBkPSJNMjEuNzMgMzQuNzEybC03LjgwOS0yLjU0NXMtLjkzMi0uMzc4LS42MzMtMS4yMzdjLjA2Mi0uMTc3LjE4Ni0uMzI4LjU1OS0uNTg4IDEuNzMxLTEuMjA2IDMyLjAyOC0xMi4wOTYgMzIuMDI4LTEyLjA5NnMuODU2LS4yODggMS4zNjEtLjA5N2MuMjMxLjA4OC4zNzguMTg3LjUwMy41NDguMDQ1LjEzMi4wNzEuNDExLjA2OC42ODktLjAwMy4yMDEtLjAyNy4zODYtLjA0NS42NzgtLjE4NCAyLjk3OC01LjcwNiAyNS4xOTgtNS43MDYgMjUuMTk4cy0uMzMgMS4zLTEuNTE0IDEuMzQ1Yy0uNDMyLjAxNi0uOTU2LS4wNzEtMS41ODItLjYxLTIuMzIzLTEuOTk4LTEwLjM1Mi03LjM5NC0xMi4xMjYtOC41OGEuMzQuMzQgMCAwMS0uMTQ2LS4yMzljLS4wMjUtLjEyNS4xMDgtLjI4LjEwOC0uMjhzMTMuOTgtMTIuNDI3IDE0LjM1Mi0xMy43MzFjLjAyOS0uMTAxLS4wNzktLjE1MS0uMjI2LS4xMDctLjkyOS4zNDItMTcuMDI1IDEwLjUwNi0xOC44MDEgMTEuNjI5LS4xMDQuMDY2LS4zOTUuMDIzLS4zOTUuMDIzIi8+PC9nPjwvc3ltYm9sPjwvc3ZnPg==',
},
categories: [
{
id: 6,
name: 'Communication',
},
],
displayName: 'Telegram',
typeVersion: 1,
},
{
id: 107,
icon: 'file:shopify.svg',
name: 'n8n-nodes-base.shopifyTrigger',
defaults: {
name: 'Shopify Trigger',
},
iconData: {
type: 'file',
fileBuffer:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTggNjYiIGZpbGw9IiNmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjx1c2UgeGxpbms6aHJlZj0iI2EiIHg9Ii41IiB5PSIuNSIvPjxzeW1ib2wgaWQ9ImEiIG92ZXJmbG93PSJ2aXNpYmxlIj48ZyBzdHJva2U9Im5vbmUiIGZpbGwtcnVsZT0ibm9uemVybyI+PHBhdGggZD0iTTQ5LjI1NSAxMi40ODRhLjYzMy42MzMgMCAwMC0uNTY0LS41MjdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdjLS4zNC0uMzc2LTEuMDkyLS4yNjYtMS4zNzYtLjE4OC0uMDM3IDAtLjc1Mi4yMjUtMS45MjIuNjA1LTEuMTM3LTMuMy0zLjE1LTYuMzA2LTYuNjk2LTYuMzA2aC0uMzAzQzI4LjQzOC42MDUgMjcuMTk0IDAgMjYuMTQ0IDBjLTguMjU2LjAzNy0xMi4yIDEwLjMzMy0xMy40MzQgMTUuNTk0bC01Ljc3IDEuNzdjLTEuNzcuNTY0LTEuODM1LjYwNS0yLjA3MyAyLjI5M0wwIDU3LjE3NSAzNi40NjggNjRsMTkuNzYzLTQuMjZjMC0uMDM3LTYuOTQtNDYuODk3LTYuOTc2LTQ3LjI1NXpNMzQuNDMxIDguODZjLS45MTcuMzAzLTEuOTYzLjYwNS0zLjEuOTQ1di0uNjhhMTUuMDMgMTUuMDMgMCAwMC0uNzUyLTQuOTk5YzEuODQ4LjI4NCAzLjEgMi4zNTcgMy44NDMgNC43MzN6bS02LjA2OC00LjI5OGMuNjAzIDEuNzc4Ljg4MyAzLjY1LjgyNiA1LjUyN3YuMzRsLTYuMzc1IDEuOTYzYzEuMjQ4LTQuNjYgMy41NS02Ljk2MiA1LjU1LTcuODN6bS0yLjQ1LTIuMjkzYTEuOTQgMS45NCAwIDAxMS4wNTUuMzM5Yy0yLjY2IDEuMjM4LTUuNDcyIDQuMzY2LTYuNjc4IDEwLjYyN2wtNS4wNDUgMS41NDZDMTYuNjY4IDEwLjAzIDE5Ljk4OCAyLjI2IDI1LjkxIDIuMjZ6IiBmaWxsPSIjOTViZjQ3Ii8+PHBhdGggZD0iTTQ4LjY5MSAxMS45NTdjLS4yMjUtLjAzNy01LjE3LS4zNzYtNS4xNy0uMzc2bC0zLjc3LTMuNzdhLjc1My43NTMgMCAwMC0uNTI3LS4yMjVMMzYuNDcyIDY0bDE5Ljc2My00LjI2LTYuOTgtNDcuMjE4YS42OC42OCAwIDAwLS41NjQtLjU2NHoiIGZpbGw9IiM1ZThlM2UiLz48cGF0aCBkPSJNMjkuNzU4IDIyLjlsLTIuNDU0IDcuMjQyYTExLjM2IDExLjM2IDAgMDAtNC43NTItMS4xMzNjLTMuODQ4IDAtNC4wMzYgMi40MTItNC4wMzYgMy4wMTggMCAzLjI5OCA4LjYzNiA0LjU2NCA4LjYzNiAxMi4zMzMgMCA2LjEtMy44ODUgMTAuMDMtOS4xIDEwLjAzLTYuMjYgMC05LjQ2Ny0zLjg4NS05LjQ2Ny0zLjg4NWwxLjY2NS01LjU1czMuMjggMi44MyA2LjA3MyAyLjgzYTIuNDcgMi40NyAwIDAwMi41NjQtMi40OWMwLTQuMzQtNy4xLTQuNTI3LTcuMS0xMS42MTggMC01Ljk2MiA0LjI5OC0xMS43NyAxMi45MzQtMTEuNzcgMy4zOTQuMDUgNS4wMTggMSA1LjAxOCAxeiIvPjwvZz48L3N5bWJvbD48L3N2Zz4=',
},
categories: [
{
id: 2,
name: 'Sales',
},
],
displayName: 'Shopify Trigger',
typeVersion: 1,
},
{
id: 325,
icon: 'file:x.svg',
name: 'n8n-nodes-base.twitter',
defaults: {
name: 'X',
},
iconData: {
type: 'file',
fileBuffer:
'data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4LjI0NCAyLjI1aDMuMzA4bC03LjIyNyA4LjI2IDguNTAyIDExLjI0SDE2LjE3bC01LjIxNC02LjgxN0w0Ljk5IDIxLjc1SDEuNjhsNy43My04LjgzNUwxLjI1NCAyLjI1SDguMDhsNC43MTMgNi4yMzF6bS0xLjE2MSAxNy41MmgxLjgzM0w3LjA4NCA0LjEyNkg1LjExN3oiPjwvcGF0aD48L3N2Zz4K',
},
categories: [
{
id: 1,
name: 'Marketing & Content',
},
],
displayName: 'X (Formerly Twitter)',
typeVersion: 2,
},
],
categories: [
{
id: 2,
name: 'Sales',
},
{
id: 19,
name: 'Marketing & Growth',
},
],
image: [
{
id: 527,
url: 'https://n8niostorageaccount.blob.core.windows.net/n8nio-strapi-blobs-prod/assets/89a078b208fe4c6181902608b1cd1332.png',
},
],
};
export const newCredentialType = (name: string): ICredentialType => ({
name,
displayName: name,
documentationUrl: name,
properties: [],
});
export const newCredential = (
opts: Pick<ICredentialsResponse, 'type'> & Partial<ICredentialsResponse>,
): ICredentialsResponse => ({
@ -233,31 +55,6 @@ export const newCredential = (
...opts,
});
export const credentialTypeTelegram: ICredentialType = {
name: 'telegramApi',
displayName: 'Telegram API',
documentationUrl: 'telegram',
properties: [
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: {
password: true,
},
default: '',
description:
'Chat with the <a href="https://telegram.me/botfather">bot father</a> to obtain the access token',
},
],
test: {
request: {
baseURL: '=https://api.telegram.org/bot{{$credentials.accessToken}}',
url: '/getMe',
},
},
};
export const credentialsTelegram1: ICredentialsResponse = {
createdAt: '2023-11-23T14:26:07.969Z',
updatedAt: '2023-11-23T14:26:07.964Z',
@ -307,3 +104,22 @@ export const credentialsTelegram2: ICredentialsResponse = {
},
sharedWith: [],
};
export {
fullSaveEmailAttachmentsToNextCloudTemplate,
fullShopifyTelegramTwitterTemplate,
} from '@/utils/testData/templateTestData';
export { credentialTypeTelegram, newCredentialType } from '@/utils/testData/credentialTypeTestData';
export {
nodeTypeHttpRequestV1,
nodeTypeNextCloudV1,
nodeTypeReadImapV1,
nodeTypeReadImapV2,
nodeTypeShopifyTriggerV1,
nodeTypeTelegramV1,
nodeTypeTelegramV1_1,
nodeTypeTwitterV1,
nodeTypeTwitterV2,
} from '@/utils/testData/nodeTypeTestData';

View file

@ -8,7 +8,11 @@ import { useRootStore } from '@/stores/n8nRoot.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
import type {
INodeCredentialDescription,
INodeCredentialsDetails,
INodeTypeDescription,
} from 'n8n-workflow';
import type {
ICredentialsResponse,
INodeUi,
@ -17,17 +21,15 @@ import type {
} from '@/Interface';
import { VIEWS } from '@/constants';
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
import type {
TemplateCredentialKey,
IWorkflowTemplateNodeWithCredentials,
} from '@/utils/templates/templateTransforms';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
import {
hasNodeCredentials,
keyFromCredentialTypeAndName,
normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useTelemetry } from '@/composables/useTelemetry';
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
export type NodeAndType = {
node: INodeUi;
@ -62,33 +64,56 @@ export type AppCredentialCount = {
count: number;
};
export type TemplateNodeWithRequiredCredential = {
node: IWorkflowTemplateNode;
requiredCredentials: INodeCredentialDescription[];
};
//#region Getter functions
/**
* Returns the nodes in the template that require credentials
* and the required credentials for each node.
*/
export const getNodesRequiringCredentials = (
nodeTypeProvider: NodeTypeProvider,
template: ITemplatesWorkflowFull,
): IWorkflowTemplateNodeWithCredentials[] => {
): TemplateNodeWithRequiredCredential[] => {
if (!template) {
return [];
}
return template.workflow.nodes.filter(hasNodeCredentials);
const nodesWithCredentials: TemplateNodeWithRequiredCredential[] = template.workflow.nodes
.map((node) => ({
node,
requiredCredentials: getNodeTypeDisplayableCredentials(nodeTypeProvider, node),
}))
.filter(({ requiredCredentials }) => requiredCredentials.length > 0);
return nodesWithCredentials;
};
export const groupNodeCredentialsByKey = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
export const groupNodeCredentialsByKey = (
nodeWithRequiredCredentials: TemplateNodeWithRequiredCredential[],
) => {
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);
for (const { node, requiredCredentials } of nodeWithRequiredCredentials) {
const normalizedNodeCreds = node.credentials
? normalizeTemplateNodeCredentials(node.credentials)
: {};
for (const credentialDescription of requiredCredentials) {
const credentialType = credentialDescription.name;
const nodeCredentialName = normalizedNodeCreds[credentialDescription.name] ?? '';
const key = keyFromCredentialTypeAndName(credentialType, nodeCredentialName);
let credentialUsages = credentialsByTypeName.get(key);
if (!credentialUsages) {
credentialUsages = {
key,
nodeTypeName: node.type,
credentialName,
credentialName: nodeCredentialName,
credentialType,
usedBy: [],
};
@ -184,10 +209,12 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
});
const nodesRequiringCredentialsSorted = computed(() => {
const credentials = template.value ? getNodesRequiringCredentials(template.value) : [];
const nodesWithCredentials = template.value
? getNodesRequiringCredentials(nodeTypesStore, template.value)
: [];
// Order by the X coordinate of the node
return sortBy(credentials, ({ position }) => position[0]);
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
});
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
@ -339,12 +366,13 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
try {
isSaving.value = true;
const createdWorkflow = await createWorkflowFromTemplate(
template.value,
credentialOverrides.value,
const createdWorkflow = await createWorkflowFromTemplate({
template: template.value,
credentialOverrides: credentialOverrides.value,
rootStore,
workflowsStore,
);
nodeTypeProvider: nodeTypesStore,
});
telemetry.track('User closed cred setup', {
completed: true,

View file

@ -293,7 +293,7 @@ export function applySpecialNodeParameters(nodeType: INodeType): void {
export function displayParameter(
nodeValues: INodeParameters,
parameter: INodeProperties | INodeCredentialDescription,
node: INode | null, // Allow null as it does also get used by credentials and they do not have versioning yet
node: Pick<INode, 'typeVersion'> | null, // Allow null as it does also get used by credentials and they do not have versioning yet
nodeValuesRoot?: INodeParameters,
) {
if (!parameter.displayOptions) {
@ -391,7 +391,7 @@ export function displayParameterPath(
nodeValues: INodeParameters,
parameter: INodeProperties | INodeCredentialDescription,
path: string,
node: INode | null,
node: Pick<INode, 'typeVersion'> | null,
) {
let resolvedNodeValues = nodeValues;
if (path !== '') {
@ -567,7 +567,7 @@ export function getNodeParameters(
nodeValues: INodeParameters | null,
returnDefaults: boolean,
returnNoneDisplayed: boolean,
node: INode | null,
node: Pick<INode, 'typeVersion'> | null,
onlySimpleTypes = false,
dataIsResolved = false,
nodeValuesRoot?: INodeParameters,