feat: Enable cred setup for workflows created from templates (no-changelog) (#8240)
## Summary Enable users to open credential setup for workflows that have been created from templates if they skip it. Next steps (will be their own PRs): - Add telemetry events - Add e2e test - Hide the button when user sets up all the credentials - Change the feature flag to a new one ## Related tickets and issues https://linear.app/n8n/issue/ADO-1637/feature-support-template-credential-setup-for-http-request-nodes-that
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
|
||||
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { assert } from '@/utils/assert';
|
||||
import CredentialsDropdown from './CredentialsDropdown.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/constants';
|
||||
|
||||
const props = defineProps({
|
||||
appName: {
|
||||
@@ -31,6 +32,8 @@ const uiStore = useUIStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
const wasModalOpenedFromHere = ref(false);
|
||||
|
||||
const availableCredentials = computed(() => {
|
||||
return credentialsStore.getCredentialsByType(props.credentialType);
|
||||
});
|
||||
@@ -48,27 +51,30 @@ const onCredentialSelected = (credentialId: string) => {
|
||||
};
|
||||
const createNewCredential = () => {
|
||||
uiStore.openNewCredential(props.credentialType, true);
|
||||
wasModalOpenedFromHere.value = true;
|
||||
$emit('credentialModalOpened');
|
||||
};
|
||||
const editCredential = () => {
|
||||
assert(props.selectedCredentialId);
|
||||
uiStore.openExistingCredential(props.selectedCredentialId);
|
||||
wasModalOpenedFromHere.value = true;
|
||||
$emit('credentialModalOpened');
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!wasModalOpenedFromHere.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
$emit('credentialSelected', credential.id);
|
||||
},
|
||||
onCredentialDeleted: (deletedCredentialId) => {
|
||||
if (!wasModalOpenedFromHere.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deletedCredentialId !== props.selectedCredentialId) {
|
||||
return;
|
||||
}
|
||||
@@ -83,6 +89,15 @@ listenForCredentialChanges({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
listenForModalChanges({
|
||||
store: uiStore,
|
||||
onModalClosed(modalName) {
|
||||
if (modalName === CREDENTIAL_EDIT_MODAL_KEY && wasModalOpenedFromHere.value) {
|
||||
wasModalOpenedFromHere.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -154,7 +154,6 @@ export default defineComponent({
|
||||
params: { name: routeWorkflowId },
|
||||
});
|
||||
}
|
||||
// this.modalBus.emit('closeAll');
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -134,7 +134,6 @@ export default defineComponent({
|
||||
window.addEventListener('keydown', this.onWindowKeydown);
|
||||
|
||||
this.eventBus?.on('close', this.closeDialog);
|
||||
this.eventBus?.on('closeAll', this.uiStore.closeAllModals);
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
@@ -143,7 +142,6 @@ export default defineComponent({
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.eventBus?.off('close', this.closeDialog);
|
||||
this.eventBus?.off('closeAll', this.uiStore.closeAllModals);
|
||||
window.removeEventListener('keydown', this.onWindowKeydown);
|
||||
},
|
||||
computed: {
|
||||
@@ -227,6 +225,7 @@ export default defineComponent({
|
||||
|
||||
.modal-content {
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,16 @@
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="SETUP_CREDENTIALS_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<SetupWorkflowCredentialsModal
|
||||
data-test-id="suggested-templates-preview-modal"
|
||||
:modal-name="modalName"
|
||||
:data="data"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,6 +207,7 @@ import {
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from './AboutModal.vue';
|
||||
@@ -229,6 +240,7 @@ import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderMo
|
||||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
||||
import SuggestedTemplatesPreviewModal from '@/components/SuggestedTemplates/SuggestedTemplatesPreviewModal.vue';
|
||||
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modals',
|
||||
@@ -263,6 +275,7 @@ export default defineComponent({
|
||||
MfaSetupModal,
|
||||
WorkflowHistoryVersionRestoreModal,
|
||||
SuggestedTemplatesPreviewModal,
|
||||
SetupWorkflowCredentialsModal,
|
||||
},
|
||||
data: () => ({
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
@@ -294,6 +307,7 @@ export default defineComponent({
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
SETUP_CREDENTIALS_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { SETUP_CREDENTIALS_MODAL_KEY, TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT } from '@/constants';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { doesNodeHaveCredentialsToFill } from '@/utils/nodes/nodeTransforms';
|
||||
import { computed, onBeforeUnmount } from 'vue';
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const posthogStore = usePostHog();
|
||||
const i18n = useI18n();
|
||||
|
||||
const showButton = computed(() => {
|
||||
const isFeatureEnabled = posthogStore.isFeatureEnabled(TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT);
|
||||
const isCreatedFromTemplate = !!workflowsStore.workflow?.meta?.templateId;
|
||||
if (!isFeatureEnabled || !isCreatedFromTemplate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodes = workflowsStore.workflow?.nodes ?? [];
|
||||
return nodes.some((node) => doesNodeHaveCredentialsToFill(nodeTypesStore, node));
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
uiStore.openModal(SETUP_CREDENTIALS_MODAL_KEY);
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uiStore.closeModal(SETUP_CREDENTIALS_MODAL_KEY);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-button
|
||||
v-if="showButton"
|
||||
:label="i18n.baseText('nodeView.setupTemplate')"
|
||||
size="large"
|
||||
@click="handleClick()"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { useSetupWorkflowCredentialsModalState } from '@/components/SetupWorkflowCredentialsModal/useSetupWorkflowCredentialsModalState';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import N8nHeading from 'n8n-design-system/components/N8nHeading';
|
||||
import AppsRequiringCredsNotice from '@/views/SetupWorkflowFromTemplateView/AppsRequiringCredsNotice.vue';
|
||||
import SetupTemplateFormStep from '@/views/SetupWorkflowFromTemplateView/SetupTemplateFormStep.vue';
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {};
|
||||
}>();
|
||||
|
||||
const {
|
||||
appCredentials,
|
||||
credentialUsages,
|
||||
selectedCredentialIdByKey,
|
||||
setInitialCredentialSelection,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
} = useSetupWorkflowCredentialsModalState();
|
||||
|
||||
onMounted(() => {
|
||||
setInitialCredentialSelection();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal width="900px" max-height="90%" :name="props.modalName">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge">
|
||||
{{ i18n.baseText('setupCredentialsModal.title') }}
|
||||
</N8nHeading>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div :class="$style.grid">
|
||||
<div :class="$style.notice" data-test-id="info-callout">
|
||||
<AppsRequiringCredsNotice :app-credentials="appCredentials" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ol :class="$style.appCredentialsContainer">
|
||||
<SetupTemplateFormStep
|
||||
v-for="(credentials, index) in credentialUsages"
|
||||
:key="credentials.key"
|
||||
:class="$style.appCredential"
|
||||
:order="index + 1"
|
||||
:credentials="credentials"
|
||||
:selected-credential-id="selectedCredentialIdByKey[credentials.key]"
|
||||
@credential-selected="setCredential($event.credentialUsageKey, $event.credentialId)"
|
||||
@credential-deselected="unsetCredential($event.credentialUsageKey)"
|
||||
/>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.grid {
|
||||
margin: 0 auto;
|
||||
margin-top: var(--spacing-l);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-width: 768px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredentialsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredential:not(:last-of-type) {
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
border-bottom: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { computed } from 'vue';
|
||||
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
|
||||
import { useCredentialSetupState } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
|
||||
|
||||
export const useSetupWorkflowCredentialsModalState = () => {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
||||
const workflowNodes = computed(() => {
|
||||
return workflowsStore.allNodes;
|
||||
});
|
||||
|
||||
const {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setSelectedCredentialId,
|
||||
unsetSelectedCredential,
|
||||
} = useCredentialSetupState(workflowNodes);
|
||||
|
||||
/**
|
||||
* Selects initial credentials. For existing workflows this means using
|
||||
* the credentials that are already set on the nodes.
|
||||
*/
|
||||
const setInitialCredentialSelection = () => {
|
||||
selectedCredentialIdByKey.value = {};
|
||||
|
||||
for (const credUsage of credentialUsages.value) {
|
||||
const typeCredentials = credentialsStore.getCredentialsByType(credUsage.credentialType);
|
||||
// Make sure there is a credential for this type with the given name
|
||||
const credential = typeCredentials.find((cred) => cred.name === credUsage.credentialName);
|
||||
if (!credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedCredentialIdByKey.value[credUsage.key] = credential.id;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the given credential to all nodes that use it.
|
||||
*/
|
||||
const setCredential = (credentialKey: TemplateCredentialKey, credentialId: string) => {
|
||||
setSelectedCredentialId(credentialKey, credentialId);
|
||||
|
||||
const usages = credentialsByKey.value.get(credentialKey);
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialName = credentialsStore.getCredentialById(credentialId)?.name;
|
||||
const credential: INodeCredentialsDetails = {
|
||||
id: credentialId,
|
||||
name: credentialName,
|
||||
};
|
||||
|
||||
usages.usedBy.forEach((node) => {
|
||||
workflowsStore.updateNodeProperties({
|
||||
name: node.name,
|
||||
properties: {
|
||||
position: node.position,
|
||||
credentials: {
|
||||
...node.credentials,
|
||||
[usages.credentialType]: credential,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// We can't use updateNodeCredentialIssues because the previous
|
||||
// step creates a new instance of the node in the store and
|
||||
// `node` no longer points to the correct node.
|
||||
nodeHelpers.updateNodeCredentialIssuesByName(node.name);
|
||||
});
|
||||
|
||||
setInitialCredentialSelection();
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes given credential from all nodes that use it.
|
||||
*/
|
||||
const unsetCredential = (credentialKey: TemplateCredentialKey) => {
|
||||
unsetSelectedCredential(credentialKey);
|
||||
|
||||
const usages = credentialsByKey.value.get(credentialKey);
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
usages.usedBy.forEach((node) => {
|
||||
const credentials = { ...node.credentials };
|
||||
delete credentials[usages.credentialType];
|
||||
|
||||
workflowsStore.updateNodeProperties({
|
||||
name: node.name,
|
||||
properties: {
|
||||
position: node.position,
|
||||
credentials,
|
||||
},
|
||||
});
|
||||
|
||||
// We can't use updateNodeCredentialIssues because the previous
|
||||
// step creates a new instance of the node in the store and
|
||||
// `node` no longer points to the correct node.
|
||||
nodeHelpers.updateNodeCredentialIssuesByName(node.name);
|
||||
});
|
||||
|
||||
setInitialCredentialSelection();
|
||||
};
|
||||
|
||||
return {
|
||||
appCredentials,
|
||||
credentialOverrides,
|
||||
credentialUsages,
|
||||
credentialsByKey,
|
||||
numFilledCredentials,
|
||||
selectedCredentialIdByKey,
|
||||
setInitialCredentialSelection,
|
||||
setCredential,
|
||||
unsetCredential,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user