From e59be66c8b6fba6492914e39868e65b081c19e2e Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Wed, 10 Dec 2025 09:51:14 +0100 Subject: [PATCH] feat(editor): First shot at adding a dynamic credential section on the credential modal (#22983) --- .../frontend/@n8n/i18n/src/locales/en.json | 6 + .../CredentialEdit/CredentialConfig.test.ts | 108 ++++++++++++++++++ .../CredentialEdit/CredentialConfig.vue | 77 +++++++++++++ .../features/credentials/credentials.types.ts | 2 + 4 files changed, 193 insertions(+) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 37f835d293a..d8e03b2417d 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -791,6 +791,12 @@ "credentialEdit.credentialConfig.recommendedAuthTypeSuffix": "(recommended)", "credentialEdit.credentialConfig.externalSecrets": "Enterprise plan users can pull in credentials from external vaults.", "credentialEdit.credentialConfig.externalSecrets.moreInfo": "More info", + "credentialEdit.credentialConfig.dynamicCredentials.title": "Set up for dynamic credentials", + "credentialEdit.credentialConfig.dynamicCredentials.infoTip": "Enable this credential to be used with workflow resolvers", + "credentialEdit.credentialConfig.dynamicCredentials.description1": "Requires a workflow resolver in Workflow settings.", + "credentialEdit.credentialConfig.dynamicCredentials.description2": "At run time it uses that resolver to select credentials based on who triggered the workflow.", + "credentialEdit.credentialConfig.dynamicCredentials.documentation": "Documentation", + "credentialEdit.credentialConfig.dynamicCredentials.docsUrl": "https://docs.n8n.io/credentials/dynamic-credentials/", "credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText": "Close", "credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText": "Keep Editing", "credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline": "Close without saving?", diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts index 92f8f029543..01391a369e3 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts @@ -158,4 +158,112 @@ describe('CredentialConfig', () => { // Verify that addCredentialTranslation was not called expect(addCredentialTranslation).not.toHaveBeenCalled(); }); + + describe('Dynamic Credentials Section', () => { + it('should not display dynamic credentials section when feature flag is disabled', async () => { + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + sharing: false, + externalSecrets: false, + }, + envFeatureFlags: { + N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: false, + }, + }, + }, + }, + }); + + renderComponent( + { + props: { + isManaged: false, + mode: 'new', + credentialPermissions: { + ...defaultRenderOptions.props!.credentialPermissions, + create: true, + }, + }, + pinia, + }, + { merge: true }, + ); + + expect(screen.queryByTestId('dynamic-credentials-section')).not.toBeInTheDocument(); + }); + + it('should not display dynamic credentials section when user lacks permissions', async () => { + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + sharing: false, + externalSecrets: false, + }, + envFeatureFlags: { + N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true, + }, + }, + }, + }, + }); + + renderComponent( + { + props: { + isManaged: false, + mode: 'edit', + credentialPermissions: { + ...defaultRenderOptions.props!.credentialPermissions, + create: false, + update: false, + }, + }, + pinia, + }, + { merge: true }, + ); + + expect(screen.queryByTestId('dynamic-credentials-section')).not.toBeInTheDocument(); + }); + + it('should display the toggle switch in the dynamic credentials section', async () => { + const pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + enterprise: { + sharing: false, + externalSecrets: false, + }, + envFeatureFlags: { + N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true, + }, + }, + }, + }, + }); + + renderComponent( + { + props: { + isManaged: false, + mode: 'new', + credentialPermissions: { + ...defaultRenderOptions.props!.credentialPermissions, + create: true, + }, + }, + pinia, + }, + { merge: true }, + ); + + expect(screen.getByTestId('dynamic-credentials-toggle')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue index 5256e7b6d50..4879d9aaf7e 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue @@ -34,15 +34,19 @@ import OauthButton from './OauthButton.vue'; import { useChatPanelStore } from '@/features/ai/assistant/chatPanel.store'; import { useAssistantStore } from '@/features/ai/assistant/assistant.store'; import FreeAiCreditsCallout from '@/app/components/FreeAiCreditsCallout.vue'; +import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag'; import { N8nCallout, + N8nIcon, N8nInfoTip, N8nInlineAskAssistantButton, N8nLink, N8nNotice, N8nText, + N8nTooltip, } from '@n8n/design-system'; +import { ElSwitch } from 'element-plus'; type Props = { mode: string; @@ -89,6 +93,7 @@ const chatPanelStore = useChatPanelStore(); const i18n = useI18n(); const telemetry = useTelemetry(); +const { check: checkEnvFeatureFlag } = useEnvFeatureFlag(); onBeforeMount(async () => { uiStore.activeCredentialType = props.credentialType.name; @@ -195,6 +200,14 @@ const assistantAlreadyAsked = computed(() => { return assistantStore.isCredTypeActive(props.credentialType); }); +const isResolvable = computed(() => { + return Boolean(props.credentialData.isResolvable); +}); + +const isDynamicCredentialsEnabled = computed(() => { + return checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'); +}); + function onDataChange(event: IUpdateInformation): void { emit('update', event); } @@ -402,6 +415,52 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => { + +
+
+ + {{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.title') }} + + + + + +
+ +
+ + {{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.description1') }} + + + {{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.description2') }} + + {{ + i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.documentation') + }} + + +
+
@@ -429,4 +488,22 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => { font-size: var(--font-size--sm); } } + +.dynamicCredentials { + display: flex; + flex-direction: column; + gap: var(--spacing--xs); + padding-top: var(--spacing--lg); + border-top: var(--border); +} + +.dynamicCredentialsHeader { + display: flex; + align-items: center; + gap: var(--spacing--3xs); +} + +.dynamicCredentialsDescription { + margin-top: var(--spacing--2xs); +} diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts index b383e32dd08..22d6ac5d798 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts @@ -15,6 +15,7 @@ export interface ICredentialsResponse extends ICredentialsEncrypted { ownedBy?: Pick; isManaged: boolean; isGlobal?: boolean; + isResolvable?: boolean; } export interface IUsedCredential { @@ -33,6 +34,7 @@ export interface ICredentialsBase { export interface ICredentialsDecryptedResponse extends ICredentialsBase, ICredentialsDecrypted { id: string; + isResolvable?: boolean; } export interface ICredentialTypeMap {