feat(editor): First shot at adding a dynamic credential section on the credential modal (#22983)

This commit is contained in:
Guillaume Jacquart
2025-12-10 09:51:14 +01:00
committed by GitHub
parent c7fd47d31f
commit e59be66c8b
4 changed files with 193 additions and 0 deletions
@@ -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?",
@@ -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();
});
});
});
@@ -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<boolean>(() => {
return assistantStore.isCredTypeActive(props.credentialType);
});
const isResolvable = computed<boolean>(() => {
return Boolean(props.credentialData.isResolvable);
});
const isDynamicCredentialsEnabled = computed<boolean>(() => {
return checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS');
});
function onDataChange(event: IUpdateInformation): void {
emit('update', event);
}
@@ -402,6 +415,52 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
</N8nInfoTip>
</template>
</EnterpriseEdition>
<div
v-if="
isDynamicCredentialsEnabled &&
((credentialPermissions.create && isNewCredential) || credentialPermissions.update)
"
:class="$style.dynamicCredentials"
data-test-id="dynamic-credentials-section"
>
<div :class="$style.dynamicCredentialsHeader">
<N8nText size="medium" weight="bold">
{{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.title') }}
</N8nText>
<N8nTooltip placement="top">
<template #content>
<div>
{{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.infoTip') }}
</div>
</template>
<N8nIcon icon="circle-help" size="small" />
</N8nTooltip>
</div>
<ElSwitch
:model-value="isResolvable"
data-test-id="dynamic-credentials-toggle"
@update:model-value="onDataChange({ name: 'isResolvable', value: $event })"
/>
<div :class="$style.dynamicCredentialsDescription">
<N8nText :tag="'div'" size="small" color="text-light">
{{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.description1') }}
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.description2') }}
<N8nLink
:to="i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.docsUrl')"
size="small"
theme="text"
underline
>
{{
i18n.baseText('credentialEdit.credentialConfig.dynamicCredentials.documentation')
}}
</N8nLink>
</N8nText>
</div>
</div>
</div>
</div>
</template>
@@ -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);
}
</style>
@@ -15,6 +15,7 @@ export interface ICredentialsResponse extends ICredentialsEncrypted {
ownedBy?: Pick<IUserResponse, 'id' | 'firstName' | 'lastName' | 'email'>;
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 {