feat: Ado 1296 spike credential setup in templates (#7786)

- Add a 'Setup template credentials' view to setup the credentials of a
template before it is created
This commit is contained in:
Tomi Turtiainen 2023-11-27 16:30:28 +02:00 committed by GitHub
parent 27e048c201
commit aae45b043b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1423 additions and 20 deletions

View file

@ -45,6 +45,7 @@ import type {
INodeExecutionData,
INodeProperties,
NodeConnectionType,
INodeCredentialsDetails,
} from 'n8n-workflow';
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
@ -248,10 +249,22 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
};
}
export interface IWorkflowTemplateNode
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
// The credentials in a template workflow have a different type than in a regular workflow
credentials?: IWorkflowTemplateNodeCredentials;
}
export interface IWorkflowTemplateNodeCredentials {
[key: string]: string | INodeCredentialsDetails;
}
export interface IWorkflowTemplate {
id: number;
name: string;
workflow: Pick<IWorkflowData, 'nodes' | 'connections' | 'settings' | 'pinData'>;
workflow: Pick<IWorkflowData, 'connections' | 'settings' | 'pinData'> & {
nodes: IWorkflowTemplateNode[];
};
}
export interface INewWorkflowData {
@ -790,6 +803,9 @@ export interface ITemplatesCollectionResponse extends ITemplatesCollectionExtend
workflows: ITemplatesWorkflow[];
}
/**
* A template without the actual workflow definition
*/
export interface ITemplatesWorkflow {
id: number;
createdAt: string;
@ -807,6 +823,9 @@ export interface ITemplatesWorkflowResponse extends ITemplatesWorkflow, IWorkflo
categories: ITemplatesCategory[];
}
/**
* A template with also the full workflow definition
*/
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
full: true;
}
@ -1302,7 +1321,7 @@ export interface INodeTypesState {
export interface ITemplateState {
categories: { [id: string]: ITemplatesCategory };
collections: { [id: string]: ITemplatesCollection };
workflows: { [id: string]: ITemplatesWorkflow };
workflows: { [id: string]: ITemplatesWorkflow | ITemplatesWorkflowFull };
workflowSearches: {
[search: string]: {
workflowIds: string[];

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useUIStore } from '@/stores';
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
import { assert } from '@/utils/assert';
import CredentialsDropdown from './CredentialsDropdown.vue';
import { useI18n } from '@/composables/useI18n';
const props = defineProps({
appName: {
type: String,
required: true,
},
credentialType: {
type: String,
required: true,
},
selectedCredentialId: {
type: String,
required: false,
},
});
const $emit = defineEmits({
credentialSelected: (_credentialId: string) => true,
credentialDeselected: () => true,
});
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const i18n = useI18n();
const availableCredentials = computed(() => {
return credentialsStore.getCredentialsByType(props.credentialType);
});
const credentialOptions = computed(() => {
return availableCredentials.value.map((credential) => ({
id: credential.id,
name: credential.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
}));
});
const onCredentialSelected = (credentialId: string) => {
$emit('credentialSelected', credentialId);
};
const createNewCredential = () => {
uiStore.openNewCredential(props.credentialType, true);
};
const editCredential = () => {
assert(props.selectedCredentialId);
uiStore.openExistingCredential(props.selectedCredentialId);
};
listenForCredentialChanges({
store: credentialsStore,
onCredentialCreated: (credential) => {
// TODO: We should have a better way to detect if credential created was due to
// user opening the credential modal from this component, as there might be
// two CredentialPicker components on the same page with same credential type.
if (credential.type !== props.credentialType) {
return;
}
$emit('credentialSelected', credential.id);
},
onCredentialDeleted: (deletedCredentialId) => {
if (deletedCredentialId !== props.selectedCredentialId) {
return;
}
const optionsWoDeleted = credentialOptions.value
.map((credential) => credential.id)
.filter((id) => id !== deletedCredentialId);
if (optionsWoDeleted.length > 0) {
$emit('credentialSelected', optionsWoDeleted[0]);
} else {
$emit('credentialDeselected');
}
},
});
</script>
<template>
<div>
<div v-if="credentialOptions.length > 0" :class="$style.dropdown">
<CredentialsDropdown
:credential-type="props.credentialType"
:credential-options="credentialOptions"
:selected-credential-id="props.selectedCredentialId"
@credential-selected="onCredentialSelected"
@new-credential="createNewCredential"
/>
<n8n-icon-button
icon="pen"
type="secondary"
:class="{
[$style.edit]: true,
[$style.invisible]: !props.selectedCredentialId,
}"
:title="i18n.baseText('nodeCredentials.updateCredential')"
@click="editCredential()"
data-test-id="credential-edit-button"
/>
</div>
<n8n-button
v-else
:label="`Create new ${props.appName} credential`"
@click="createNewCredential"
/>
</div>
</template>
<style lang="scss" module>
.dropdown {
display: flex;
}
.edit {
display: flex;
justify-content: center;
align-items: center;
min-width: 20px;
margin-left: var(--spacing-2xs);
font-size: var(--font-size-s);
}
.invisible {
visibility: hidden;
}
</style>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import { useI18n } from '@/composables';
export type CredentialOption = {
id: string;
name: string;
typeDisplayName: string | undefined;
};
const props = defineProps({
credentialOptions: {
type: Array as PropType<CredentialOption[]>,
required: true,
},
selectedCredentialId: {
type: String,
required: false,
},
});
const $emit = defineEmits({
credentialSelected: (_credentialId: string) => true,
newCredential: () => true,
});
const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
const onCredentialSelected = (credentialId: string) => {
if (credentialId === NEW_CREDENTIALS_TEXT) {
$emit('newCredential');
} else {
$emit('credentialSelected', credentialId);
}
};
</script>
<template>
<n8n-select
size="small"
:modelValue="props.selectedCredentialId"
@update:modelValue="onCredentialSelected"
>
<n8n-option
v-for="item in props.credentialOptions"
:data-test-id="`node-credentials-select-item-${item.id}`"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div :class="[$style.credentialOption, 'mt-2xs mb-2xs']">
<n8n-text bold>{{ item.name }}</n8n-text>
<n8n-text size="small">{{ item.typeDisplayName }}</n8n-text>
</div>
</n8n-option>
<n8n-option
data-test-id="node-credentials-select-item-new"
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</template>
<style lang="scss" module>
.credentialOption {
display: flex;
flex-direction: column;
}
</style>

View file

@ -206,9 +206,7 @@ export default defineComponent({
methods: {
async initView(loadWorkflow: boolean): Promise<void> {
if (loadWorkflow) {
if (this.nodeTypesStore.allNodeTypes.length === 0) {
await this.nodeTypesStore.getNodeTypes();
}
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
await this.openWorkflow(this.$route.params.name);
this.uiStore.nodeViewInitialized = false;
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {

View file

@ -401,6 +401,7 @@ export const enum VIEWS {
EXECUTION_DEBUG = 'ExecutionDebug',
EXECUTION_HOME = 'ExecutionsLandingPage',
TEMPLATE = 'TemplatesWorkflowView',
TEMPLATE_SETUP = 'TemplatesWorkflowSetupView',
TEMPLATES = 'TemplatesSearchView',
CREDENTIALS = 'CredentialsView',
VARIABLES = 'VariablesView',

View file

@ -63,6 +63,7 @@
"generic.editor": "Editor",
"generic.seePlans": "See plans",
"generic.loading": "Loading",
"generic.and": "and",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -2271,5 +2272,11 @@
"executionUsage.label.executions": "Executions",
"executionUsage.button.upgrade": "Upgrade plan",
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
"executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating."
"executionUsage.ranOutOfExecutions.text": "Youre out of executions. Upgrade your plan to keep automating.",
"templateSetup.title": "Setup '{name}' template",
"templateSetup.instructions": "You need {0} account to setup this template",
"templateSetup.skip": "Skip",
"templateSetup.continue.button": "Continue",
"templateSetup.continue.tooltip": "Connect to {numLeft} more app to continue | Connect to {numLeft} more apps to continue",
"templateSetup.credential.description": "The credential you select will be used in the {0} node of the workflow template. | The credential you select will be used in the {0} nodes of the workflow template."
}

View file

@ -42,6 +42,8 @@ const SigninView = async () => import('./views/SigninView.vue');
const SignupView = async () => import('./views/SignupView.vue');
const TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
const SetupWorkflowFromTemplateView = async () =>
import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
const CredentialsView = async () => import('@/views/CredentialsView.vue');
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
@ -123,6 +125,28 @@ export const routes = [
middleware: ['authenticated'],
},
},
{
path: '/templates/:id/setup',
name: VIEWS.TEMPLATE_SETUP,
components: {
default: SetupWorkflowFromTemplateView,
sidebar: MainSidebar,
},
meta: {
templatesEnabled: true,
getRedirect: getTemplatesRedirect,
telemetry: {
getProperties(route: RouteLocation) {
const templatesStore = useTemplatesStore();
return {
template_id: route.params.id,
wf_template_repo_session_id: templatesStore.currentSessionId,
};
},
},
middleware: ['authenticated'],
},
},
{
path: '/templates/',
name: VIEWS.TEMPLATES,

View file

@ -41,6 +41,8 @@ const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];
export type CredentialsStore = ReturnType<typeof useCredentialsStore>;
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
state: (): ICredentialsState => ({
credentialTypes: {},
@ -400,3 +402,42 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
},
},
});
/**
* Helper function for listening to credential changes in the store
*/
export const listenForCredentialChanges = (opts: {
store: CredentialsStore;
onCredentialCreated?: (credential: ICredentialsResponse) => void;
onCredentialUpdated?: (credential: ICredentialsResponse) => void;
onCredentialDeleted?: (credentialId: string) => void;
}): void => {
const { store, onCredentialCreated, onCredentialDeleted, onCredentialUpdated } = opts;
const listeningForActions = ['createNewCredential', 'updateCredential', 'deleteCredential'];
store.$onAction((result) => {
const { name, after, args } = result;
after(async (returnValue) => {
if (!listeningForActions.includes(name)) {
return;
}
switch (name) {
case 'createNewCredential':
const createdCredential = returnValue as ICredentialsResponse;
onCredentialCreated?.(createdCredential);
break;
case 'updateCredential':
const updatedCredential = returnValue as ICredentialsResponse;
onCredentialUpdated?.(updatedCredential);
break;
case 'deleteCredential':
const credentialId = args[0].id;
onCredentialDeleted?.(credentialId);
break;
}
});
});
};

View file

@ -263,6 +263,14 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
this.setNodeTypes(nodeTypes);
}
},
/**
* Loads node types if they haven't been loaded yet
*/
async loadNodeTypesIfNotLoaded(): Promise<void> {
if (Object.keys(this.nodeTypes).length === 0) {
await this.getNodeTypes();
}
},
async getNodeTranslationHeaders(): Promise<void> {
const rootStore = useRootStore();
const headers = await getNodeTranslationHeaders(rootStore.getRestApiContext);

View file

@ -47,6 +47,12 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
getTemplateById() {
return (id: string): null | ITemplatesWorkflow => this.workflows[id];
},
getFullTemplateById() {
return (id: string): null | ITemplatesWorkflowFull => {
const template = this.workflows[id];
return template && 'full' in template && template.full ? template : null;
};
},
getCollectionById() {
return (id: string): null | ITemplatesCollection => this.collections[id];
},

View file

@ -0,0 +1,8 @@
/**
* Asserts given condition
*/
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
throw new Error(message ?? 'Assertion failed');
}
}

View file

@ -0,0 +1,16 @@
// Feature flags
export const enum FeatureFlag {
templateCredentialsSetup = 'template-credentials-setup',
}
const hasLocaleStorageKey = (key: string): boolean => {
try {
// Local storage might not be available in all envs e.g. when user has
// disabled it in their browser
return !!localStorage.getItem(key);
} catch (e) {
return false;
}
};
export const isFeatureFlagEnabled = (flag: FeatureFlag): boolean => hasLocaleStorageKey(flag);

View file

@ -0,0 +1,33 @@
import type { I18nClass } from '@/plugins/i18n';
/**
* Formats a list of items into a string. Each item is formatted using
* the given function and the are separated by a comma except for the last
* item which is separated by "and".
*
* @example
* formatList(['a', 'b', 'c'], {
* formatFn: (x) => `"${x}"`
* i18n
* });
* // => '"a", "b" and "c"'
*/
export const formatList = <T>(
list: T[],
opts: {
formatFn: (item: T) => string;
i18n: I18nClass;
},
) => {
const { i18n, formatFn } = opts;
if (list.length === 0) {
return '';
}
if (list.length === 1) {
return formatFn(list[0]);
}
const allButLast = list.slice(0, -1);
const last = list[list.length - 1];
return `${allButLast.map(formatFn).join(', ')} ${i18n.baseText('generic.and')} ${formatFn(last)}`;
};

View file

@ -7,7 +7,6 @@ import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector';
import type {
ConnectionTypes,
IConnection,
INode,
ITaskData,
INodeExecutionData,
NodeInputConnections,
@ -336,7 +335,7 @@ export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) =>
});
};
export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
export const getLeftmostTopNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
return nodes.reduce((leftmostTop, node) => {
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
return leftmostTop;
@ -963,7 +962,7 @@ export const getInputEndpointUUID = (
return `${nodeId}${INPUT_UUID_KEY}${getScope(connectionType) || ''}${inputIndex}`;
};
export const getFixedNodesList = (workflowNodes: INode[]) => {
export const getFixedNodesList = <T extends { position: XYPosition }>(workflowNodes: T[]): T[] => {
const nodes = [...workflowNodes];
if (nodes.length) {

View file

@ -0,0 +1,37 @@
import type { INodeUi, IWorkflowData, IWorkflowTemplate } from '@/Interface';
import { getNewWorkflow } from '@/api/workflows';
import type { useRootStore } from '@/stores/n8nRoot.store';
import type { useWorkflowsStore } from '@/stores/workflows.store';
import { getFixedNodesList } from '@/utils/nodeViewUtils';
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
import type { INodeCredentialsDetails } from 'n8n-workflow';
/**
* Creates a new workflow from a template
*/
export async function createWorkflowFromTemplate(
template: IWorkflowTemplate,
credentialOverrides: Record<string, INodeCredentialsDetails>,
rootStore: ReturnType<typeof useRootStore>,
workflowsStore: ReturnType<typeof useWorkflowsStore>,
) {
const workflowData = await getNewWorkflow(rootStore.getRestApiContext, template.name);
const nodesWithCreds = replaceAllTemplateNodeCredentials(
template.workflow.nodes,
credentialOverrides,
);
const nodes = getFixedNodesList(nodesWithCreds) as INodeUi[];
const connections = template.workflow.connections;
const workflowToCreate: IWorkflowData = {
name: workflowData.name,
nodes,
connections,
active: false,
// Ignored: pinData, settings, tags, versionId, meta
};
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowToCreate);
return createdWorkflow;
}

View file

@ -0,0 +1,81 @@
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode &
Required<Pick<IWorkflowTemplateNode, 'credentials'>>;
/**
* 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.
*/
export const normalizeTemplateNodeCredentials = (
credentials: IWorkflowTemplateNodeCredentials,
): NormalizedTemplateNodeCredentials => {
return Object.fromEntries(
Object.entries(credentials).map(([key, value]) => {
return typeof value === 'string' ? [key, value] : [key, value.name];
}),
);
};
/**
* Replaces the credentials of a node with the given replacements
*
* @example
* const nodeCredentials = { twitterOAuth1Api: "twitter" };
* const toReplaceByType = { twitter: {
* id: "BrEOZ5Cje6VYh9Pc",
* name: "X OAuth account"
* }};
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByType);
* // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } }
*/
export const replaceTemplateNodeCredentials = (
nodeCredentials: IWorkflowTemplateNodeCredentials,
toReplaceByName: Record<string, INodeCredentialsDetails>,
) => {
if (!nodeCredentials) {
return undefined;
}
const newNodeCredentials: INodeCredentials = {};
const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials);
for (const credentialType in normalizedCredentials) {
const credentialNameInTemplate = normalizedCredentials[credentialType];
const toReplaceWith = toReplaceByName[credentialNameInTemplate];
if (toReplaceWith) {
newNodeCredentials[credentialType] = toReplaceWith;
}
}
return newNodeCredentials;
};
/**
* Replaces the credentials of all template workflow nodes with the given
* replacements
*/
export const replaceAllTemplateNodeCredentials = (
nodes: IWorkflowTemplateNode[],
toReplaceWith: Record<string, INodeCredentialsDetails>,
) => {
return nodes.map((node) => {
if (hasNodeCredentials(node)) {
return {
...node,
credentials: replaceTemplateNodeCredentials(node.credentials, toReplaceWith),
};
}
return node;
});
};

View file

@ -0,0 +1,9 @@
/**
* The credentials of a node in a template workflow. Map from credential
* type name to credential name.
* @example
* {
* twitterOAuth1Api: "Twitter credentials"
* }
*/
export type NormalizedTemplateNodeCredentials = Record<string, string>;

View file

@ -110,12 +110,9 @@ export default defineComponent({
this.credentialsStore.fetchAllCredentials(),
this.credentialsStore.fetchCredentialTypes(false),
this.externalSecretsStore.fetchAllSecrets(),
this.nodeTypesStore.loadNodeTypesIfNotLoaded(),
];
if (this.nodeTypesStore.allNodeTypes.length === 0) {
loadPromises.push(this.nodeTypesStore.getNodeTypes());
}
await Promise.all(loadPromises);
await this.usersStore.fetchUsers(); // Can be loaded in the background, used for filtering

View file

@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue';
import N8nNotice from 'n8n-design-system/components/N8nNotice';
import type { AppCredentials } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { storeToRefs } from 'pinia';
import { formatList } from '@/utils/formatters/listFormatter';
import { useI18n } from '@/composables';
const i18n = useI18n();
const store = useSetupTemplateStore();
const { appCredentials } = storeToRefs(store);
const formatApp = (app: AppCredentials) => `<b>${app.credentials.length}x ${app.appName}</b>`;
const appNodeCounts = computed(() => {
return formatList(appCredentials.value, {
formatFn: formatApp,
i18n,
});
});
</script>
<template>
<n8n-notice theme="info">
<i18n-t tag="span" keypath="templateSetup.instructions" scope="global">
<span v-html="appNodeCounts" />
</i18n-t>
</n8n-notice>
</template>

View file

@ -0,0 +1,9 @@
<template>
<i class="el-icon-success" />
</template>
<style lang="scss" scoped>
i {
color: var(--prim-color-alt-a);
}
</style>

View file

@ -0,0 +1,161 @@
<script setup lang="ts">
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 { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import type { IWorkflowTemplateNode } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
// Props
const props = defineProps({
order: {
type: Number,
required: true,
},
credentialName: {
type: String,
required: true,
},
});
// Stores
const setupTemplateStore = useSetupTemplateStore();
const nodeTypesStore = useNodeTypesStore();
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 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, {
formatFn: formatNodeName,
i18n,
});
});
const selectedCredentialId = computed(
() => setupTemplateStore.selectedCredentialIdByName[props.credentialName],
);
//#endregion Computed
//#region Methods
const onCredentialSelected = (credentialId: string) => {
setupTemplateStore.setSelectedCredentialId(props.credentialName, credentialId);
};
const onCredentialDeselected = () => {
setupTemplateStore.unsetSelectedCredential(props.credentialName);
};
//#endregion Methods
</script>
<template>
<li :class="$style.container">
<n8n-heading tag="h2" size="large">
<div v-if="nodeType" :class="$style.heading">
<span :class="$style.headingOrder">{{ order }}.</span>
<span :class="$style.headingIcon"><NodeIcon :node-type="nodeType" /></span>
{{ appName }}
</div>
</n8n-heading>
<p :class="$style.description">
<i18n-t
tag="span"
keypath="templateSetup.credential.description"
:plural="credentials.usedBy.length"
scope="global"
>
<span v-html="nodeNames" />
</i18n-t>
</p>
<div :class="$style.credentials">
<CredentialPicker
:class="$style.credentialPicker"
:app-name="appName"
:credentialType="credentialType"
:selectedCredentialId="selectedCredentialId"
@credential-selected="onCredentialSelected"
@credential-deselected="onCredentialDeselected"
/>
<IconSuccess
:class="{
[$style.credentialOk]: true,
[$style.invisible]: !selectedCredentialId,
}"
/>
</div>
</li>
</template>
<style lang="scss" module>
.container {
list-style: none;
}
.heading {
display: flex;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.headingOrder {
font-weight: var(--font-weight-bold);
margin-right: var(--spacing-xs);
}
.headingIcon {
margin-right: var(--spacing-2xs);
}
.description {
margin-bottom: var(--spacing-l);
}
.credentials {
max-width: 400px;
display: flex;
align-items: center;
}
.credentialPicker {
flex: 1;
}
.credentialOk {
margin-left: var(--spacing-2xs);
}
.invisible {
visibility: hidden;
}
</style>

View file

@ -0,0 +1,202 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSetupTemplateStore } from './setupTemplate.store';
import N8nHeading from 'n8n-design-system/components/N8nHeading';
import N8nLink from 'n8n-design-system/components/N8nLink';
import AppsRequiringCredsNotice from './AppsRequiringCredsNotice.vue';
import SetupTemplateFormStep from './SetupTemplateFormStep.vue';
import TemplatesView from '../TemplatesView.vue';
import { VIEWS } from '@/constants';
import { useExternalHooks, useI18n, useTelemetry } from '@/composables';
// Store
const setupTemplateStore = useSetupTemplateStore();
const i18n = useI18n();
const $telemetry = useTelemetry();
const $externalHooks = useExternalHooks();
// Router
const route = useRoute();
const $router = useRouter();
//#region Computed
const templateId = computed(() =>
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
);
const title = computed(() => setupTemplateStore.template?.name ?? 'unknown');
const isReady = computed(() => !setupTemplateStore.isLoading);
const skipSetupUrl = computed(() => {
const resolvedRoute = $router.resolve({
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId.value },
});
return resolvedRoute.fullPath;
});
const buttonTooltip = computed(() => {
const numLeft = setupTemplateStore.numCredentialsLeft;
return i18n.baseText('templateSetup.continue.tooltip', {
adjustToNumber: numLeft,
interpolate: { numLeft: numLeft.toString() },
});
});
//#endregion Computed
//#region Watchers
watch(templateId, async (newTemplateId) => {
setupTemplateStore.setTemplateId(newTemplateId);
await setupTemplateStore.loadTemplateIfNeeded();
});
//#endregion Watchers
//#region Methods
const onSkipSetup = async (event: MouseEvent) => {
event.preventDefault();
await setupTemplateStore.skipSetup({
$externalHooks,
$telemetry,
$router,
});
};
const skipIfTemplateHasNoCreds = async () => {
const isTemplateLoaded = !!setupTemplateStore.template;
if (!isTemplateLoaded) {
return;
}
if (setupTemplateStore.credentialUsages.length === 0) {
await setupTemplateStore.skipSetup({
$externalHooks,
$telemetry,
$router,
});
}
};
//#endregion Methods
//#region Lifecycle hooks
setupTemplateStore.setTemplateId(templateId.value);
onMounted(async () => {
await setupTemplateStore.init();
await skipIfTemplateHasNoCreds();
});
//#endregion Lifecycle hooks
</script>
<template>
<TemplatesView :goBackEnabled="true">
<template #header>
<n8n-heading v-if="isReady" tag="h1" size="2xlarge"
>{{ $locale.baseText('templateSetup.title', { interpolate: { name: title } }) }}
</n8n-heading>
<n8n-loading v-else variant="h1" />
</template>
<template #content>
<div :class="$style.grid">
<div :class="$style.gridContent">
<div :class="$style.notice">
<AppsRequiringCredsNotice v-if="isReady" />
<n8n-loading v-else variant="p" />
</div>
<div>
<ol v-if="isReady" :class="$style.appCredentialsContainer">
<SetupTemplateFormStep
:class="$style.appCredential"
v-bind:key="credentials.credentialName"
v-for="(credentials, index) in setupTemplateStore.credentialUsages"
:order="index + 1"
:credentials="credentials"
:credentialName="credentials.credentialName"
/>
</ol>
<div v-else :class="$style.appCredentialsContainer">
<n8n-loading :class="$style.appCredential" variant="p" :rows="3" />
<n8n-loading :class="$style.appCredential" variant="p" :rows="3" />
</div>
</div>
<div :class="$style.actions">
<n8n-link :href="skipSetupUrl" :newWindow="false" @click="onSkipSetup($event)">{{
$locale.baseText('templateSetup.skip')
}}</n8n-link>
<n8n-tooltip
v-if="isReady"
:content="buttonTooltip"
:disabled="setupTemplateStore.numCredentialsLeft === 0"
>
<n8n-button
:label="$locale.baseText('templateSetup.continue.button')"
:disabled="setupTemplateStore.numCredentialsLeft > 0 || setupTemplateStore.isSaving"
@click="setupTemplateStore.createWorkflow($router)"
/>
</n8n-tooltip>
<div v-else>
<n8n-loading variant="button" />
</div>
</div>
</div>
</div>
</template>
</TemplatesView>
</template>
<style lang="scss" module>
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
padding: var(--spacing-l) var(--spacing-l) 0;
justify-content: center;
}
.gridContent {
grid-column: 3 / span 8;
@media (max-width: 800px) {
grid-column: 3 / span 8;
}
@media (max-width: 640px) {
grid-column: 2 / span 10;
}
}
.notice {
margin-bottom: var(--spacing-2xl);
}
.appCredentialsContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2xl);
}
.appCredential:not(:last-of-type) {
padding-bottom: var(--spacing-2xl);
border-bottom: 1px solid var(--prim-gray-540);
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-3xl);
margin-bottom: var(--spacing-3xl);
}
</style>

View file

@ -0,0 +1,148 @@
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import {
getAppCredentials,
getAppsRequiringCredentials,
groupNodeCredentialsByName,
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
const objToMap = <T>(obj: Record<string, T>) => {
return new Map<string, T>(Object.entries(obj));
};
describe('SetupWorkflowFromTemplateView store', () => {
const nodesByName = {
Twitter: {
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,
},
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('groupNodeCredentialsByName', () => {
it('returns an empty array if there are no nodes', () => {
expect(groupNodeCredentialsByName([])).toEqual(new Map());
});
it('returns credentials grouped by name', () => {
expect(groupNodeCredentialsByName(Object.values(nodesByName))).toEqual(
objToMap({
twitter: {
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
telegram: {
credentialName: 'telegram',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [nodesByName.Telegram],
},
shopify: {
credentialName: 'shopify',
credentialType: 'shopifyApi',
nodeTypeName: 'n8n-nodes-base.shopifyTrigger',
usedBy: [nodesByName.shopify],
},
}),
);
});
});
describe('getAppsRequiringCredentials', () => {
it('returns an empty array if there are no nodes', () => {
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppsRequiringCredentials(new Map(), appNameByNodeTypeName)).toEqual([]);
});
it('returns an array of apps requiring credentials', () => {
const credentialUsages: Map<string, CredentialUsages> = objToMap({
twitter: {
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
});
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppsRequiringCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
{
appName: 'Twitter',
count: 1,
},
]);
});
});
describe('getAppCredentials', () => {
it('returns an empty array if there are no nodes', () => {
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppCredentials([], appNameByNodeTypeName)).toEqual([]);
});
it('returns an array of apps requiring credentials', () => {
const credentialUsages: CredentialUsages[] = [
{
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
];
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
{
appName: 'Twitter',
credentials: [
{
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
],
},
]);
});
});
});

View file

@ -0,0 +1,350 @@
import sortBy from 'lodash-es/sortBy';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { Router } from 'vue-router';
import {
useCredentialsStore,
useNodeTypesStore,
useRootStore,
useTemplatesStore,
useWorkflowsStore,
} from '@/stores';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
import type {
ICredentialsResponse,
IExternalHooks,
INodeUi,
ITemplatesWorkflowFull,
IWorkflowTemplateNode,
} from '@/Interface';
import type { Telemetry } from '@/plugins/telemetry';
import { VIEWS } from '@/constants';
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
import {
hasNodeCredentials,
normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
export type NodeAndType = {
node: INodeUi;
nodeType: INodeTypeDescription;
};
export type RequiredCredentials = {
node: INodeUi;
credentialName: string;
credentialType: string;
};
export type CredentialUsages = {
credentialName: string;
credentialType: string;
nodeTypeName: string;
usedBy: IWorkflowTemplateNode[];
};
export type AppCredentials = {
appName: string;
credentials: CredentialUsages[];
};
export type AppCredentialCount = {
appName: string;
count: number;
};
//#region Getter functions
export const getNodesRequiringCredentials = (
template: ITemplatesWorkflowFull,
): IWorkflowTemplateNodeWithCredentials[] => {
if (!template) {
return [];
}
return template.workflow.nodes.filter(hasNodeCredentials);
};
export const groupNodeCredentialsByName = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
const credentialsByName = new Map<string, CredentialUsages>();
for (const node of nodes) {
const normalizedCreds = normalizeTemplateNodeCredentials(node.credentials);
for (const credentialType in normalizedCreds) {
const credentialName = normalizedCreds[credentialType];
let credentialUsages = credentialsByName.get(credentialName);
if (!credentialUsages) {
credentialUsages = {
nodeTypeName: node.type,
credentialName,
credentialType,
usedBy: [],
};
credentialsByName.set(credentialName, credentialUsages);
}
credentialUsages.usedBy.push(node);
}
}
return credentialsByName;
};
export const getAppCredentials = (
credentialUsages: CredentialUsages[],
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
) => {
const credentialsByAppName = new Map<string, AppCredentials>();
for (const credentialUsage of credentialUsages) {
const nodeTypeName = credentialUsage.nodeTypeName;
const appName = getAppNameByNodeType(nodeTypeName) ?? nodeTypeName;
const appCredentials = credentialsByAppName.get(appName);
if (appCredentials) {
appCredentials.credentials.push(credentialUsage);
} else {
credentialsByAppName.set(appName, {
appName,
credentials: [credentialUsage],
});
}
}
return Array.from(credentialsByAppName.values());
};
export const getAppsRequiringCredentials = (
credentialUsagesByName: Map<string, CredentialUsages>,
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
) => {
const credentialsByAppName = new Map<string, AppCredentialCount>();
for (const credentialUsage of credentialUsagesByName.values()) {
const node = credentialUsage.usedBy[0];
const appName = getAppNameByNodeType(node.type, node.typeVersion) ?? node.type;
const appCredentials = credentialsByAppName.get(appName);
if (appCredentials) {
appCredentials.count++;
} else {
credentialsByAppName.set(appName, {
appName,
count: 1,
});
}
}
return Array.from(credentialsByAppName.values());
};
//#endregion Getter functions
/**
* Store for managing the state of the SetupWorkflowFromTemplateView
*/
export const useSetupTemplateStore = defineStore('setupTemplate', () => {
//#region State
const templateId = ref<string>('');
const isLoading = ref(true);
const isSaving = ref(false);
/**
* 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']>
>({});
//#endregion State
const templatesStore = useTemplatesStore();
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
//#region Getters
const template = computed(() => {
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
});
const nodesRequiringCredentialsSorted = computed(() => {
const credentials = template.value ? getNodesRequiringCredentials(template.value) : [];
// Order by the X coordinate of the node
return sortBy(credentials, ({ position }) => position[0]);
});
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
};
const credentialsByName = computed(() => {
return groupNodeCredentialsByName(nodesRequiringCredentialsSorted.value);
});
const credentialUsages = computed(() => {
return Array.from(credentialsByName.value.values());
});
const appCredentials = computed(() => {
return getAppCredentials(credentialUsages.value, appNameByNodeType);
});
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 credential = credentialsStore.getCredentialById(credentialId);
if (!credential) {
continue;
}
overrides[credentialNameInTemplate] = {
id: credentialId,
name: credential.name,
};
}
return overrides;
});
const numCredentialsLeft = computed(() => {
return credentialUsages.value.length - Object.keys(selectedCredentialIdByName.value).length;
});
//#endregion Getters
//#region Actions
const setTemplateId = (id: string) => {
templateId.value = id;
};
/**
* Loads the template if it hasn't been loaded yet.
*/
const loadTemplateIfNeeded = async () => {
if (!!template.value || !templateId.value) {
return;
}
await templatesStore.fetchTemplateById(templateId.value);
};
/**
* Initializes the store for a specific template.
*/
const init = async () => {
isLoading.value = true;
try {
selectedCredentialIdByName.value = {};
await Promise.all([
credentialsStore.fetchAllCredentials(),
credentialsStore.fetchCredentialTypes(false),
nodeTypesStore.loadNodeTypesIfNotLoaded(),
loadTemplateIfNeeded(),
]);
} finally {
isLoading.value = false;
}
};
/**
* Skips the setup and goes directly to the workflow view.
*/
const skipSetup = async (opts: {
$externalHooks: IExternalHooks;
$telemetry: Telemetry;
$router: Router;
}) => {
const { $externalHooks, $telemetry, $router } = opts;
const telemetryPayload = {
source: 'workflow',
template_id: templateId.value,
wf_template_repo_session_id: templatesStore.currentSessionId,
};
await $externalHooks.run('templatesWorkflowView.openWorkflow', telemetryPayload);
$telemetry.track('User inserted workflow template', telemetryPayload, {
withPostHog: true,
});
// Replace the URL so back button doesn't come back to this setup view
await $router.replace({
name: VIEWS.TEMPLATE_IMPORT,
params: { id: templateId.value },
});
};
/**
* Creates a workflow from the template and navigates to the workflow view.
*/
const createWorkflow = async ($router: Router) => {
if (!template.value) {
return;
}
try {
isSaving.value = true;
const createdWorkflow = await createWorkflowFromTemplate(
template.value,
credentialOverrides.value,
rootStore,
workflowsStore,
);
// Replace the URL so back button doesn't come back to this setup view
await $router.replace({
name: VIEWS.WORKFLOW,
params: { name: createdWorkflow.id },
});
} finally {
isSaving.value = false;
}
};
const setSelectedCredentialId = (credentialName: string, credentialId: string) => {
selectedCredentialIdByName.value[credentialName] = credentialId;
};
const unsetSelectedCredential = (credentialName: string) => {
delete selectedCredentialIdByName.value[credentialName];
};
//#endregion Actions
return {
credentialsByName,
isLoading,
isSaving,
appCredentials,
nodesRequiringCredentialsSorted,
template,
credentialUsages,
selectedCredentialIdByName,
numCredentialsLeft,
createWorkflow,
skipSetup,
init,
loadTemplateIfNeeded,
setTemplateId,
setSelectedCredentialId,
unsetSelectedCredential,
};
});

View file

@ -16,7 +16,7 @@
v-if="template"
:label="$locale.baseText('template.buttons.useThisWorkflowButton')"
size="large"
@click="openWorkflow(template.id, $event)"
@click="openTemplateSetup(template.id, $event)"
/>
<n8n-loading :loading="!template" :rows="1" variant="button" />
</div>
@ -68,6 +68,7 @@ import { setPageTitle } from '@/utils';
import { VIEWS } from '@/constants';
import { useTemplatesStore } from '@/stores/templates.store';
import { usePostHog } from '@/stores/posthog.store';
import { FeatureFlag, isFeatureFlagEnabled } from '@/utils/featureFlag';
export default defineComponent({
name: 'TemplatesWorkflowView',
@ -94,7 +95,7 @@ export default defineComponent({
};
},
methods: {
openWorkflow(id: string, e: PointerEvent) {
openTemplateSetup(id: string, e: PointerEvent) {
const telemetryPayload = {
source: 'workflow',
template_id: id,
@ -105,12 +106,23 @@ export default defineComponent({
this.$telemetry.track('User inserted workflow template', telemetryPayload, {
withPostHog: true,
});
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
window.open(route.href, '_blank');
return;
if (isFeatureFlagEnabled(FeatureFlag.templateCredentialsSetup)) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_SETUP, params: { id } });
window.open(route.href, '_blank');
return;
} else {
void this.$router.push({ name: VIEWS.TEMPLATE_SETUP, params: { id } });
}
} else {
void this.$router.push({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
window.open(route.href, '_blank');
return;
} else {
void this.$router.push({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
}
}
},
onHidePreview() {