mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(editor): Implement modal to edit/create credential resolver, and resolver workflow settings (#22977)
This commit is contained in:
parent
9f641cdca1
commit
432545a4c8
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
|
|
|
|||
|
|
@ -2769,6 +2769,7 @@ export interface IWorkflowSettings {
|
|||
timeSavedPerExecution?: number;
|
||||
timeSavedMode?: 'fixed' | 'dynamic';
|
||||
availableInMCP?: boolean;
|
||||
credentialResolverId?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowFEMeta {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user