feat(editor): Implement modal to edit/create credential resolver, and resolver workflow settings (#22977)

This commit is contained in:
Guillaume Jacquart 2025-12-10 10:32:33 +01:00 committed by GitHub
parent 9f641cdca1
commit 432545a4c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1119 additions and 11 deletions

View File

@ -579,7 +579,7 @@ describe('LoadNodesAndCredentials', () => {
beforeEach(() => {
// Enable the feature flag for tests
process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true';
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
mockLogger = {
debug: jest.fn(),
@ -599,12 +599,12 @@ describe('LoadNodesAndCredentials', () => {
afterEach(() => {
// Clean up the environment variable after each test
delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS;
delete process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
});
it('should not inject hooks when feature flag is disabled', () => {
// Disable the feature flag
delete process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS;
delete process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS;
const triggerNode: INodeTypeDescription = {
name: 'webhookTrigger',
@ -639,7 +639,7 @@ describe('LoadNodesAndCredentials', () => {
);
// Re-enable for other tests
process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS = 'true';
process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS = 'true';
});
it('should not inject hooks if no hooks exist', () => {

View File

@ -282,7 +282,7 @@ export class LoadNodesAndCredentials {
}
private shouldInjectContextEstablishmentHooks() {
return process.env.N8N_ENV_FEAT_CONTEXT_ESTABLISHMENT_HOOKS === 'true';
return process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS === 'true';
}
private injectContextEstablishmentHooks() {

View File

@ -3175,6 +3175,31 @@
"workflowSettings.executionTimeout": "Timeout Workflow",
"workflowSettings.tags": "Tags",
"workflowSettings.timezone": "Timezone",
"workflowSettings.credentialResolver": "Dynamic credential resolver",
"workflowSettings.credentialResolver.placeholder": "Select resolver",
"workflowSettings.credentialResolver.createNew": "Create new resolver",
"workflowSettings.credentialResolver.edit": "Edit resolver",
"workflowSettings.credentialResolver.none": "Default - None",
"workflowSettings.helpTexts.credentialResolver": "The resolver uses the identity of the user triggering the workflow to pick the right account for all dynamic credentials in this workflow.",
"credentialResolverEdit.title.create": "Create Credential Resolver",
"credentialResolverEdit.title.edit": "Edit Credential Resolver",
"credentialResolverEdit.saveSuccess.title": "Credential Resolver was saved successfully",
"credentialResolverEdit.deleteSuccess.title": "Credential Resolver was deleted successfully",
"credentialResolverEdit.defaultName": "New resolver",
"credentialResolverEdit.type.label": "Type",
"credentialResolverEdit.type.placeholder": "Select resolver type",
"credentialResolverEdit.config.label": "Configuration",
"credentialResolverEdit.error.save": "Failed to save credential resolver",
"credentialResolverEdit.error.delete": "Failed to delete credential resolver",
"credentialResolverEdit.error.loadTypes": "Failed to load resolver types",
"credentialResolverEdit.delete": "Delete",
"credentialResolverEdit.confirmMessage.deleteResolver.headline": "Delete Credential Resolver?",
"credentialResolverEdit.confirmMessage.deleteResolver.message": "Are you sure you want to delete the credential resolver \"{savedResolverName}\"?",
"credentialResolverEdit.confirmMessage.deleteResolver.confirmButtonText": "Yes, delete",
"credentialResolverEdit.sidebar.configuration": "Configuration",
"credentialResolverEdit.sidebar.details": "Details",
"credentialResolverEdit.details.id": "ID",
"credentialResolverEdit.details.notSaved": "Not saved yet",
"workflowSettings.timeSavedPerExecution": "Estimated time saved",
"workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution",
"workflowSettings.timeSavedPerExecution.tooltip": "Total time savings are summarised in the Overview page.",

View File

@ -0,0 +1,45 @@
import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function getCredentialResolvers(
context: IRestApiContext,
): Promise<CredentialResolver[]> {
return await makeRestApiRequest(context, 'GET', '/credential-resolvers');
}
export async function getCredentialResolverTypes(
context: IRestApiContext,
): Promise<CredentialResolverType[]> {
return await makeRestApiRequest(context, 'GET', '/credential-resolvers/types');
}
export async function getCredentialResolver(
context: IRestApiContext,
resolverId: string,
): Promise<CredentialResolver> {
return await makeRestApiRequest(context, 'GET', `/credential-resolvers/${resolverId}`);
}
export async function createCredentialResolver(
context: IRestApiContext,
payload: { name: string; type: string; config: Record<string, unknown> },
): Promise<CredentialResolver> {
return await makeRestApiRequest(context, 'POST', '/credential-resolvers', payload);
}
export async function updateCredentialResolver(
context: IRestApiContext,
resolverId: string,
payload: { name: string; type: string; config: Record<string, unknown> },
): Promise<CredentialResolver> {
return await makeRestApiRequest(context, 'PATCH', `/credential-resolvers/${resolverId}`, payload);
}
export async function deleteCredentialResolver(
context: IRestApiContext,
resolverId: string,
): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', `/credential-resolvers/${resolverId}`);
}

View File

@ -1,6 +1,7 @@
export * from './api-keys';
export * from './cloudPlans';
export * from './communityNodes';
export * from './credentialResolvers';
export * from './ctas';
export * from './eventbus.ee';
export * from './events';

View File

@ -0,0 +1,210 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@/app/composables/useToast';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants';
import * as restApiClient from '@n8n/rest-api-client';
import type { CredentialResolverType } from '@n8n/api-types';
vi.mock('@/app/composables/useToast', () => {
const showError = vi.fn();
return {
useToast: () => ({
showError,
}),
};
});
vi.mock('@n8n/rest-api-client', async (importOriginal) => {
const actual = await importOriginal<typeof restApiClient>();
return {
...actual,
getCredentialResolverTypes: vi.fn(),
getCredentialResolver: vi.fn(),
createCredentialResolver: vi.fn(),
updateCredentialResolver: vi.fn(),
};
});
const ModalStub = {
template: `
<div>
<slot name="header" />
<slot name="content" />
</div>
`,
};
const CredentialInputsStub = {
template: '<div data-test-id="credential-inputs" />',
};
const global = {
stubs: {
Modal: ModalStub,
CredentialInputs: CredentialInputsStub,
},
};
const mockResolverTypes: CredentialResolverType[] = [
{
name: 'test-resolver',
displayName: 'Test Resolver',
description: 'A test resolver',
options: [
{
name: 'apiKey',
type: 'string',
displayName: 'API Key',
required: true,
description: 'Enter your API key',
placeholder: 'Enter key',
},
],
},
];
const renderModal = createComponentRenderer(CredentialResolverEditModal);
let pinia: ReturnType<typeof createTestingPinia>;
describe('CredentialResolverEditModal', () => {
let rootStore: MockedStore<typeof useRootStore>;
let toast: ReturnType<typeof useToast>;
beforeEach(() => {
pinia = createTestingPinia();
rootStore = mockedStore(useRootStore);
toast = useToast();
rootStore.restApiContext = {
baseUrl: 'http://localhost',
pushRef: 'test-ref',
};
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Component rendering', () => {
it('should load resolver types on mount', async () => {
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
},
pinia,
global,
});
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolverTypes).toHaveBeenCalledWith(
rootStore.restApiContext,
);
});
});
});
describe('Create resolver', () => {
it('should render in create mode without resolverId', async () => {
const onSave = vi.fn();
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave,
},
},
pinia,
global,
});
// Wait for types to load
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolverTypes).toHaveBeenCalled();
});
// Verify it doesn't try to load an existing resolver
expect(restApiClient.getCredentialResolver).not.toHaveBeenCalled();
});
});
describe('Edit resolver', () => {
it('should load existing resolver when resolverId is provided', async () => {
const mockResolver = {
id: 'existing-resolver-id',
name: 'Existing Resolver',
type: 'test-resolver',
config: '{}',
decryptedConfig: { apiKey: 'secret-key' },
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(restApiClient.getCredentialResolver).mockResolvedValue(mockResolver);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'existing-resolver-id',
},
},
pinia,
global,
});
await vi.waitFor(() => {
expect(restApiClient.getCredentialResolver).toHaveBeenCalledWith(
rootStore.restApiContext,
'existing-resolver-id',
);
});
});
});
describe('Error handling', () => {
it('should show error toast when loading resolver types fails', async () => {
const error = new Error('Failed to load types');
vi.mocked(restApiClient.getCredentialResolverTypes).mockRejectedValue(error);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
},
pinia,
global,
});
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(error, expect.any(String));
});
});
it('should show error toast when loading resolver fails', async () => {
const error = new Error('Load failed');
vi.mocked(restApiClient.getCredentialResolver).mockRejectedValue(error);
renderModal({
props: {
modalName: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'existing-resolver-id',
},
},
pinia,
global,
});
await vi.waitFor(() => {
expect(toast.showError).toHaveBeenCalledWith(error, expect.any(String));
});
});
});
});

View File

@ -0,0 +1,477 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import {
N8nIconButton,
N8nSelect,
N8nOption,
N8nMenuItem,
N8nText,
N8nInlineTextEdit,
type IMenuItem,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { useMessage } from '@/app/composables/useMessage';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM } from '../constants';
import { createEventBus } from '@n8n/utils/event-bus';
import Modal from './Modal.vue';
import SaveButton from './SaveButton.vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { CredentialResolverType, CredentialResolver } from '@n8n/api-types';
import {
getCredentialResolverTypes,
getCredentialResolver,
createCredentialResolver,
updateCredentialResolver,
deleteCredentialResolver,
} from '@n8n/rest-api-client';
import type {
INodeProperties,
ICredentialDataDecryptedObject,
CredentialInformation,
} from 'n8n-workflow';
import type { IUpdateInformation } from '@/Interface';
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
const props = defineProps<{
modalName: string;
data?: {
resolverId?: string;
onSave?: (resolverId: string) => void;
onDelete?: (resolverId: string) => void;
};
}>();
const modalBus = createEventBus();
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const rootStore = useRootStore();
const activeTab = ref('configuration');
const isLoading = ref(false);
const isSaving = ref(false);
const isDeleting = ref(false);
const resolverName = ref('');
const resolverType = ref('');
const resolverConfig = ref<Record<string, unknown>>({});
const availableTypes = ref<CredentialResolverType[]>([]);
const hasUnsavedChanges = ref(false);
const isEditMode = computed(() => !!props.data?.resolverId);
// Type guard to validate and convert resolver config to credential data
const isCredentialInformation = (value: unknown): value is CredentialInformation => {
return (
typeof value === 'string' || (Array.isArray(value) && value.every((v) => typeof v === 'string'))
);
};
const toCredentialData = (config: Record<string, unknown>): ICredentialDataDecryptedObject => {
const result: ICredentialDataDecryptedObject = {};
for (const [key, value] of Object.entries(config)) {
if (isCredentialInformation(value)) {
result[key] = value;
} else if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
// Convert primitive values to string for compatibility
result[key] = String(value);
}
}
return result;
};
const selectedType = computed(() => {
return availableTypes.value.find((t) => t.name === resolverType.value);
});
// Helper function to convert resolver option to INodeProperties
// The explicit return type provides type narrowing from unknown to INodeProperties
const toNodeProperty = (option: Record<string, unknown>): INodeProperties => {
return {
name: typeof option.name === 'string' ? option.name : '',
type: (typeof option.type === 'string' ? option.type : 'string') as INodeProperties['type'],
displayName: typeof option.displayName === 'string' ? option.displayName : '',
default: (option.default ?? '') as INodeProperties['default'],
...(typeof option.required === 'boolean' && { required: option.required }),
...(typeof option.description === 'string' && { description: option.description }),
...(typeof option.placeholder === 'string' && { placeholder: option.placeholder }),
};
};
const credentialProperties = computed<INodeProperties[]>(() => {
if (!selectedType.value?.options) return [];
// Transform resolver options to INodeProperties format
return selectedType.value.options.map(toNodeProperty);
});
const credentialData = computed<ICredentialDataDecryptedObject>(() => {
return toCredentialData(resolverConfig.value);
});
const canSave = computed(() => {
return resolverName.value.trim() !== '' && resolverType.value !== '';
});
const sidebarItems = computed<IMenuItem[]>(() => [
{
id: 'configuration',
label: i18n.baseText('credentialResolverEdit.sidebar.configuration'),
position: 'top',
},
{
id: 'details',
label: i18n.baseText('credentialResolverEdit.sidebar.details'),
position: 'top',
},
]);
const loadResolverTypes = async () => {
try {
availableTypes.value = await getCredentialResolverTypes(rootStore.restApiContext);
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.loadTypes'));
}
};
const loadResolver = async () => {
if (!props.data?.resolverId) return;
try {
const resolver = await getCredentialResolver(rootStore.restApiContext, props.data.resolverId);
resolverName.value = resolver.name;
resolverType.value = resolver.type;
resolverConfig.value = resolver.decryptedConfig || {};
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.save'));
}
};
const beforeClose = async () => {
if (hasUnsavedChanges.value) {
// Could add confirmation dialog here
}
return true;
};
const save = async () => {
if (!canSave.value) return;
isSaving.value = true;
try {
const payload = {
name: resolverName.value.trim(),
type: resolverType.value,
config: resolverConfig.value,
};
let savedResolver: CredentialResolver;
if (isEditMode.value && props.data?.resolverId) {
savedResolver = await updateCredentialResolver(
rootStore.restApiContext,
props.data.resolverId,
payload,
);
} else {
savedResolver = await createCredentialResolver(rootStore.restApiContext, payload);
}
if (props.data?.onSave) {
props.data.onSave(savedResolver.id);
}
toast.showMessage({
title: i18n.baseText('credentialResolverEdit.saveSuccess.title'),
type: 'success',
});
hasUnsavedChanges.value = false;
modalBus.emit('close');
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.save'));
} finally {
isSaving.value = false;
}
};
const onConfigUpdate = (updateData: IUpdateInformation) => {
resolverConfig.value = {
...resolverConfig.value,
[updateData.name]: updateData.value,
};
hasUnsavedChanges.value = true;
};
const onNameEdit = (newName: string) => {
resolverName.value = newName;
hasUnsavedChanges.value = true;
};
const onTabSelect = (tabId: string) => {
activeTab.value = tabId;
};
const deleteResolver = async () => {
if (!props.data?.resolverId) return;
const savedResolverName = resolverName.value;
const deleteConfirmed = await message.confirm(
i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.message', {
interpolate: { savedResolverName },
}),
i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.headline'),
{
confirmButtonText: i18n.baseText(
'credentialResolverEdit.confirmMessage.deleteResolver.confirmButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
try {
isDeleting.value = true;
await deleteCredentialResolver(rootStore.restApiContext, props.data.resolverId);
hasUnsavedChanges.value = false;
if (props.data?.onDelete) {
props.data.onDelete(props.data.resolverId);
}
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.delete'));
isDeleting.value = false;
return;
}
isDeleting.value = false;
modalBus.emit('close');
toast.showMessage({
title: i18n.baseText('credentialResolverEdit.deleteSuccess.title'),
type: 'success',
});
};
onMounted(async () => {
isLoading.value = true;
await loadResolverTypes();
if (isEditMode.value) {
await loadResolver();
} else {
// Set default name for new resolvers
resolverName.value = i18n.baseText('credentialResolverEdit.defaultName');
}
isLoading.value = false;
});
</script>
<template>
<Modal
:name="CREDENTIAL_RESOLVER_EDIT_MODAL_KEY"
:custom-class="$style.resolverModal"
:event-bus="modalBus"
:loading="isLoading"
:before-close="beforeClose"
width="70%"
height="80%"
>
<template #header>
<div :class="$style.header">
<div :class="$style.resolverInfo">
<div :class="$style.resolverIcon">
<N8nIconButton icon="database" type="tertiary" size="large" :disabled="true" />
</div>
<div :class="$style.resolverName">
<N8nInlineTextEdit
v-if="resolverName"
data-test-id="credential-resolver-name"
:model-value="resolverName"
@update:model-value="onNameEdit"
/>
<N8nText v-if="selectedType" size="small" tag="p" color="text-light">
{{ selectedType.displayName }}
</N8nText>
</div>
</div>
<div :class="$style.resolverActions">
<N8nIconButton
v-if="isEditMode"
:title="i18n.baseText('credentialResolverEdit.delete')"
icon="trash-2"
type="tertiary"
:disabled="isSaving"
:loading="isDeleting"
data-test-id="credential-resolver-delete-button"
@click="deleteResolver"
/>
<SaveButton
:saved="false"
:is-saving="isSaving"
:disabled="!canSave"
data-test-id="credential-resolver-save-button"
@click="save"
/>
</div>
</div>
</template>
<template #content>
<div :class="$style.container" data-test-id="credential-resolver-edit-dialog">
<div :class="$style.sidebar">
<N8nMenuItem
v-for="item in sidebarItems"
:key="item.id"
:item="item"
:active="activeTab === item.id"
@click="() => onTabSelect(item.id)"
/>
</div>
<div v-if="activeTab === 'configuration'" :class="$style.mainContent">
<div :class="$style.formGroup">
<label :class="$style.label">
{{ i18n.baseText('credentialResolverEdit.type.label') }}
</label>
<N8nSelect
v-model="resolverType"
:placeholder="i18n.baseText('credentialResolverEdit.type.placeholder')"
data-test-id="credential-resolver-type-select"
@update:model-value="() => (hasUnsavedChanges = true)"
>
<N8nOption
v-for="type in availableTypes"
:key="type.name"
:label="type.displayName"
:value="type.name"
>
</N8nOption>
</N8nSelect>
</div>
<div v-if="credentialProperties.length > 0" :class="$style.configSection">
<CredentialInputs
:credential-properties="credentialProperties"
:credential-data="credentialData"
documentation-url=""
@update="onConfigUpdate"
/>
</div>
</div>
<div v-if="activeTab === 'details'" :class="$style.mainContent">
<div :class="$style.formGroup">
<label :class="$style.label">
{{ i18n.baseText('credentialResolverEdit.details.id') }}
</label>
<N8nText v-if="props.data?.resolverId" color="text-base">
{{ props.data.resolverId }}
</N8nText>
<N8nText v-else color="text-light">
{{ i18n.baseText('credentialResolverEdit.details.notSaved') }}
</N8nText>
</div>
</div>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.resolverModal {
--dialog--max-width: 1200px;
--dialog--close--spacing--top: 31px;
--dialog--max-height: 750px;
:global(.el-dialog__header) {
padding-bottom: 0;
border-bottom: var(--border);
}
:global(.el-dialog__body) {
padding-top: var(--spacing--lg);
position: relative;
}
}
.mainContent {
flex: 1;
overflow: auto;
padding-bottom: 100px;
}
.resolverName {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing--4xs);
}
.sidebar {
max-width: 170px;
min-width: 170px;
margin-right: var(--spacing--lg);
flex-grow: 1;
ul {
padding: 0 !important;
}
}
.header {
display: flex;
}
.container {
display: flex;
height: 100%;
}
.resolverInfo {
display: flex;
align-items: center;
flex-direction: row;
flex-grow: 1;
margin-bottom: var(--spacing--lg);
}
.resolverActions {
display: flex;
flex-direction: row;
align-items: center;
margin-right: var(--spacing--xl);
margin-bottom: var(--spacing--lg);
> * {
margin-left: var(--spacing--2xs);
}
}
.resolverIcon {
display: flex;
align-items: center;
margin-right: var(--spacing--xs);
}
.formGroup {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--md);
}
.label {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
color: var(--color--text);
}
.configSection {
margin-top: var(--spacing--lg);
}
</style>

View File

@ -33,6 +33,7 @@ import {
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -118,6 +119,7 @@ import VariableModal from '@/features/settings/environments.ee/components/Variab
import WorkflowDescriptionModal from '@/app/components/WorkflowDescriptionModal.vue';
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue';
import CredentialResolverEditModal from '@/app/components/CredentialResolverEditModal.vue';
</script>
<template>
@ -438,6 +440,12 @@ import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/co
<WorkflowHistoryPublishModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_RESOLVER_EDIT_MODAL_KEY">
<template #default="{ modalName, data }">
<CredentialResolverEditModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<!-- Dynamic modals from modules -->
<DynamicModalLoader />
</div>

View File

@ -11,6 +11,8 @@ import WorkflowSettingsVue from '@/app/components/WorkflowSettings.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import * as restApiClient from '@n8n/rest-api-client';
import { mock } from 'vitest-mock-extended';
vi.mock('vue-router', async () => ({
useRouter: vi.fn(),
@ -25,6 +27,14 @@ vi.mock('vue-router', async () => ({
},
}));
vi.mock('@n8n/rest-api-client', async (importOriginal) => {
const actual = await importOriginal<typeof restApiClient>();
return {
...actual,
getCredentialResolvers: vi.fn(),
};
});
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
@ -50,9 +60,13 @@ describe('WorkflowSettingsVue', () => {
settingsStore = mockedStore(useSettingsStore);
sourceControlStore = mockedStore(useSourceControlStore);
settingsStore.settings = {
settingsStore.settings = mock<FrontendSettings>({
enterprise: {},
} as FrontendSettings;
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
releaseChannel: 'stable',
});
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.workflowId = '1';
searchWorkflowsSpy = workflowsStore.searchWorkflows.mockResolvedValue([
@ -309,4 +323,161 @@ describe('WorkflowSettingsVue', () => {
expect(timeSavedPerExecutionInput).toBeDisabled();
});
describe('Credential Resolver', () => {
const mockResolvers = [
{
id: 'resolver-1',
name: 'Test Resolver 1',
type: 'test-type',
config: '{}',
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: 'resolver-2',
name: 'Test Resolver 2',
type: 'test-type',
config: '{}',
createdAt: new Date(),
updatedAt: new Date(),
},
];
beforeEach(() => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
});
it('should render credential resolver dropdown', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
expect(getByTestId('workflow-settings-credential-resolver')).toBeVisible();
});
it('should load credential resolvers on mount', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
});
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-credential-resolver'),
);
// Should have 2 resolvers
expect(dropdownItems).toHaveLength(2);
expect(dropdownItems[0]).toHaveTextContent('Test Resolver 1');
expect(dropdownItems[1]).toHaveTextContent('Test Resolver 2');
});
it('should show "New" button for creating a new resolver', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(getByTestId('workflow-settings-credential-resolver-create-new')).toBeInTheDocument();
});
});
it('should not show "Edit" button when no resolver is selected', async () => {
const { queryByTestId } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(queryByTestId('workflow-settings-credential-resolver-edit')).not.toBeInTheDocument();
});
});
it('should show "Edit" button when a resolver is selected', async () => {
workflowsStore.workflowSettings.credentialResolverId = 'resolver-1';
const { getByTestId } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(getByTestId('workflow-settings-credential-resolver-edit')).toBeInTheDocument();
});
});
it('should select a resolver from dropdown', async () => {
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
});
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-credential-resolver'),
);
// Select "Test Resolver 1"
await userEvent.click(dropdownItems[0]);
await userEvent.click(getByRole('button', { name: 'Save' }));
const callArgs = workflowsStore.updateWorkflow.mock.calls[0];
expect(callArgs[0]).toBe('1');
expect(callArgs[1].settings?.credentialResolverId).toBe('resolver-1');
});
it('should save workflow with selected resolver', async () => {
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
});
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-credential-resolver'),
);
// Select "Test Resolver 2"
await userEvent.click(dropdownItems[1]);
await userEvent.click(getByRole('button', { name: 'Save' }));
const callArgs = workflowsStore.updateWorkflow.mock.calls[0];
expect(callArgs[0]).toBe('1');
expect(callArgs[1].settings?.credentialResolverId).toBe('resolver-2');
});
it('should disable credential resolver dropdown when environment is read-only', async () => {
sourceControlStore.preferences.branchReadOnly = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
const dropdownContainer = getByTestId('workflow-settings-credential-resolver');
const input = dropdownContainer.querySelector('input');
expect(input).toBeDisabled();
});
it('should disable credential resolver dropdown when user has no update permission', async () => {
workflowsStore.getWorkflowById.mockImplementation(() => ({
id: '1',
name: 'Test Workflow',
active: true,
activeVersionId: 'v1',
isArchived: false,
nodes: [],
connections: {},
createdAt: 1,
updatedAt: 1,
versionId: '123',
scopes: ['workflow:read'],
}));
const { getByTestId } = createComponent({ pinia });
await nextTick();
const dropdownContainer = getByTestId('workflow-settings-credential-resolver');
const input = dropdownContainer.querySelector('input');
expect(input).toBeDisabled();
});
});
});

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from '@/app/composables/useToast';
import type { ITimeoutHMS, IWorkflowSettings, IWorkflowShortResponse } from '@/Interface';
@ -12,7 +12,15 @@ import {
NODE_CREATOR_OPEN_SOURCES,
TIME_SAVED_NODE_TYPE,
} from '@/app/constants';
import { N8nButton, N8nIcon, N8nInput, N8nOption, N8nSelect, N8nTooltip } from '@n8n/design-system';
import {
N8nButton,
N8nIcon,
N8nIconButton,
N8nInput,
N8nOption,
N8nSelect,
N8nTooltip,
} from '@n8n/design-system';
import type { WorkflowSettings } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import { useSettingsStore } from '@/app/stores/settings.store';
@ -31,6 +39,11 @@ import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { getCredentialResolvers } from '@n8n/rest-api-client';
import type { CredentialResolver } from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } from '../constants';
import { ElCol, ElRow, ElSwitch } from 'element-plus';
@ -42,6 +55,7 @@ const modalBus = createEventBus();
const telemetry = useTelemetry();
const { isEligibleForMcpAccess, trackMcpAccessEnabledForWorkflow, mcpTriggerMap } = useMcp();
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
@ -50,6 +64,7 @@ const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const workflowsEEStore = useWorkflowsEEStore();
const nodeCreatorStore = useNodeCreatorStore();
const uiStore = useUIStore();
const isLoading = ref(true);
const workflowCallerPolicyOptions = ref<Array<{ key: string; value: string }>>([]);
@ -64,6 +79,8 @@ const executionOrderOptions = ref<Array<{ key: string; value: string }>>([
const timezones = ref<Array<{ key: string; value: string }>>([]);
const workflowSettings = ref<IWorkflowSettings>({} as IWorkflowSettings);
const workflows = ref<IWorkflowShortResponse[]>([]);
const credentialResolvers = ref<CredentialResolver[]>([]);
const credentialResolverSelectRef = ref<InstanceType<typeof N8nSelect> | null>(null);
const executionTimeout = ref(0);
const maxExecutionTimeout = ref(0);
const timeoutHMS = ref<ITimeoutHMS>({ hours: 0, minutes: 0, seconds: 0 });
@ -71,6 +88,7 @@ const timeoutHMS = ref<ITimeoutHMS>({ hours: 0, minutes: 0, seconds: 0 });
const helpTexts = computed(() => ({
errorWorkflow: i18n.baseText('workflowSettings.helpTexts.errorWorkflow'),
timezone: i18n.baseText('workflowSettings.helpTexts.timezone'),
credentialResolver: i18n.baseText('workflowSettings.helpTexts.credentialResolver'),
saveDataErrorExecution: i18n.baseText('workflowSettings.helpTexts.saveDataErrorExecution'),
saveDataSuccessExecution: i18n.baseText('workflowSettings.helpTexts.saveDataSuccessExecution'),
saveExecutionProgress: i18n.baseText('workflowSettings.helpTexts.saveExecutionProgress'),
@ -94,6 +112,9 @@ const defaultValues = ref({
const isMCPEnabled = computed(
() => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
const isCredentialResolverEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const workflowName = computed(() => workflowsStore.workflowName);
const workflowId = computed(() => workflowsStore.workflowId);
@ -346,6 +367,58 @@ const loadWorkflows = async (searchTerm?: string) => {
workflows.value = workflowsData;
};
const loadCredentialResolvers = async () => {
try {
const resolvers = await getCredentialResolvers(rootStore.restApiContext);
credentialResolvers.value = resolvers;
} catch (error) {
toast.showError(error, i18n.baseText('workflowSettings.showError.fetchSettings.title'));
}
};
const handleCreateNewResolver = async () => {
// Close the dropdown first
credentialResolverSelectRef.value?.blur();
await nextTick();
// Open modal with callback
uiStore.openModalWithData({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: async (resolverId: string) => {
// Reload resolvers list
await loadCredentialResolvers();
// Set the newly created resolver
workflowSettings.value.credentialResolverId = resolverId;
},
},
});
};
const handleEditResolver = async () => {
if (!workflowSettings.value.credentialResolverId) return;
// Open modal in edit mode
uiStore.openModalWithData({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: workflowSettings.value.credentialResolverId,
onSave: async () => {
// Reload resolvers list after editing
await loadCredentialResolvers();
},
onDelete: async (deletedResolverId: string) => {
// Reload resolvers list after deletion
await loadCredentialResolvers();
// If the deleted resolver was selected, reset to None
if (workflowSettings.value.credentialResolverId === deletedResolverId) {
workflowSettings.value.credentialResolverId = undefined;
}
},
},
});
};
const { debounce } = useDebounce();
const debouncedLoadWorkflows = debounce(loadWorkflows, { debounceTime: 300, trailing: true });
@ -487,7 +560,7 @@ onMounted(async () => {
isLoading.value = true;
try {
await Promise.all([
const promises = [
workflowsStore.fetchWorkflow(workflowId.value),
loadWorkflows(),
loadSaveDataErrorExecutionOptions(),
@ -496,7 +569,13 @@ onMounted(async () => {
loadSaveManualOptions(),
loadTimezones(),
loadWorkflowCallerPolicyOptions(),
]);
];
if (isCredentialResolverEnabled.value) {
promises.push(loadCredentialResolvers());
}
await Promise.all(promises);
} catch (error) {
toast.showError(
error,
@ -649,6 +728,61 @@ onBeforeUnmount(() => {
</N8nSelect>
</ElCol>
</ElRow>
<ElRow v-if="isCredentialResolverEnabled" data-test-id="credential-resolver">
<ElCol :span="10" :class="$style['setting-name']">
{{ i18n.baseText('workflowSettings.credentialResolver') }}
<N8nTooltip placement="top">
<template #content>
<div v-text="helpTexts.credentialResolver"></div>
</template>
<N8nIcon icon="circle-help" />
</N8nTooltip>
</ElCol>
<ElCol :span="14" class="ignore-key-press-canvas">
<div :class="$style['credential-resolver-container']">
<N8nSelect
ref="credentialResolverSelectRef"
v-model="workflowSettings.credentialResolverId"
:placeholder="i18n.baseText('workflowSettings.credentialResolver.placeholder')"
filterable
:disabled="readOnlyEnv || !workflowPermissions.update"
:limit-popper-width="true"
data-test-id="workflow-settings-credential-resolver"
>
<N8nOption
v-for="resolver in credentialResolvers"
:key="resolver.id"
:label="resolver.name"
:value="resolver.id"
>
</N8nOption>
<template #footer>
<button
type="button"
:class="$style['create-new-button']"
:disabled="readOnlyEnv || !workflowPermissions.update"
data-test-id="workflow-settings-credential-resolver-create-new"
@click="handleCreateNewResolver"
>
<N8nIcon size="xsmall" icon="plus" />
{{ i18n.baseText('workflowSettings.credentialResolver.createNew') }}
</button>
</template>
</N8nSelect>
<N8nIconButton
v-if="workflowSettings.credentialResolverId"
icon="pen"
type="tertiary"
:text="true"
size="small"
:disabled="readOnlyEnv || !workflowPermissions.update"
:title="i18n.baseText('workflowSettings.credentialResolver.edit')"
data-test-id="workflow-settings-credential-resolver-edit"
@click="handleEditResolver"
/>
</div>
</ElCol>
</ElRow>
<div v-if="isSharingEnabled" data-test-id="workflow-caller-policy">
<ElRow>
<ElCol :span="10" :class="$style['setting-name']">
@ -1232,4 +1366,37 @@ onBeforeUnmount(() => {
text-decoration: underline;
}
}
.credential-resolver-container {
display: flex;
align-items: center;
}
.create-new-button {
display: flex;
width: 100%;
gap: var(--spacing--3xs);
align-items: center;
font-weight: var(--font-weight--bold);
padding: var(--spacing--xs) var(--spacing--md);
background-color: var(--color--background--light-2);
color: var(--color--text--shade-1);
border: 0;
border-top: var(--border);
box-shadow: var(--shadow--light);
clip-path: inset(-12px 0 0 0);
&:not([disabled]) {
cursor: pointer;
&:hover {
color: var(--color--primary);
}
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View File

@ -38,3 +38,4 @@ export const EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY = 'templatesDataQuality';
export const WORKFLOW_DESCRIPTION_MODAL_KEY = 'workflowDescription';
export const WORKFLOW_PUBLISH_MODAL_KEY = 'workflowPublish';
export const WORKFLOW_HISTORY_PUBLISH_MODAL_KEY = 'workflowHistoryPublish';
export const CREDENTIAL_RESOLVER_EDIT_MODAL_KEY = 'credentialResolverEdit';

View File

@ -35,6 +35,7 @@ import {
WORKFLOW_DESCRIPTION_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@ -156,6 +157,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {

View File

@ -2769,6 +2769,7 @@ export interface IWorkflowSettings {
timeSavedPerExecution?: number;
timeSavedMode?: 'fixed' | 'dynamic';
availableInMCP?: boolean;
credentialResolverId?: string;
}
export interface WorkflowFEMeta {