feat(editor): Add credentials resolvers list UI (#23082)

This commit is contained in:
Raúl Gómez Morales
2025-12-12 10:05:28 +01:00
committed by GitHub
parent 3ced9b3cc3
commit 08754cbeb2
12 changed files with 1054 additions and 90 deletions
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M23.0877 0H13.9944L16.8048 8.75784H25.8982L18.5411 13.9805L21.3523 22.788C26.0879 19.3383 27.6352 14.1173 25.899 8.75784L23.0877 0ZM2.09058 8.75784H11.1839L13.9944 0H4.90182L2.09058 8.75784C0.353512 14.1173 1.90165 19.3383 6.63727 22.788L9.44769 13.9805L2.09058 8.75784ZM6.63727 22.788L13.9944 28L21.3515 22.788L13.9944 17.4896L6.63727 22.788Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

@@ -17,6 +17,7 @@ import NodeSuccess from './custom/node-success.svg';
import NodeTrash from './custom/node-trash.svg';
import NodeValidationError from './custom/node-validation-error.svg';
import PopOut from './custom/pop-out.svg';
import Resolver from './custom/resolver.svg';
import Retry from './custom/retry.svg';
import RunOnce from './custom/run-once.svg';
import Schema from './custom/schema.svg';
@@ -43,6 +44,7 @@ import IconLucideArrowRight from '~icons/lucide/arrow-right';
import IconLucideArrowRightFromLine from '~icons/lucide/arrow-right-from-line';
import IconLucideArrowRightToLine from '~icons/lucide/arrow-right-to-line';
import IconLucideArrowUp from '~icons/lucide/arrow-up';
import IconLucideArrowUpRight from '~icons/lucide/arrow-up-right';
import IconLucideAtSign from '~icons/lucide/at-sign';
import IconLucideBan from '~icons/lucide/ban';
import IconLucideBell from '~icons/lucide/bell';
@@ -187,6 +189,7 @@ import IconLucideShare from '~icons/lucide/share';
import IconLucideSlidersHorizontal from '~icons/lucide/sliders-horizontal';
import IconLucideSmile from '~icons/lucide/smile';
import IconLucideSparkles from '~icons/lucide/sparkles';
import IconLucideSplit from '~icons/lucide/split';
import IconLucideSquare from '~icons/lucide/square';
import IconLucideSquareCheck from '~icons/lucide/square-check';
import IconLucideSquareMinus from '~icons/lucide/square-minus';
@@ -474,6 +477,7 @@ export const updatedIconSet = {
'node-trash': NodeTrash,
mcp: Mcp,
lovable: Lovable,
resolver: Resolver,
// lucide
'align-right': IconLucideAlignRight,
@@ -486,6 +490,7 @@ export const updatedIconSet = {
'arrow-right-from-line': IconLucideArrowRightFromLine,
'arrow-right-to-line': IconLucideArrowRightToLine,
'arrow-up': IconLucideArrowUp,
'arrow-up-right': IconLucideArrowUpRight,
'at-sign': IconLucideAtSign,
ban: IconLucideBan,
bell: IconLucideBell,
@@ -629,6 +634,7 @@ export const updatedIconSet = {
'sliders-horizontal': IconLucideSlidersHorizontal,
smile: IconLucideSmile,
sparkles: IconLucideSparkles,
split: IconLucideSplit,
square: IconLucideSquare,
'square-check': IconLucideSquareCheck,
'square-minus': IconLucideSquareMinus,
@@ -43,6 +43,7 @@
"generic.allow": "Allow",
"generic.deny": "Deny",
"generic.docs": "Docs",
"generic.documentation": "Documentation",
"generic.back": "Back",
"generic.cancel": "Cancel",
"generic.connect": "Connect",
@@ -3190,6 +3191,14 @@
"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.",
"credentialResolver.addNew": "Add Resolver",
"credentialResolver.view.title": "Credential resolvers",
"credentialResolver.view.description": "Resolvers take an incoming user identity and pick the account used by dynamic credentials when the workflow runs. Select a resolver in Workflow settings.",
"credentialResolver.view.learnMore": "You can learn more in the ",
"credentialResolver.action.edit": "Edit",
"credentialResolver.action.delete": "Delete",
"credentialResolver.item.updated": "Last updated",
"credentialResolver.item.created": "Created",
"credentialResolverEdit.title.create": "Create Credential Resolver",
"credentialResolverEdit.title.edit": "Edit Credential Resolver",
"credentialResolverEdit.saveSuccess.title": "Credential Resolver was saved successfully",
@@ -12,20 +12,18 @@ import {
} 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 { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY } 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 type { CredentialResolver } from '@n8n/api-types';
import {
getCredentialResolverTypes,
getCredentialResolver,
createCredentialResolver,
updateCredentialResolver,
deleteCredentialResolver,
} from '@n8n/rest-api-client';
import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers';
import type {
INodeProperties,
ICredentialDataDecryptedObject,
@@ -46,21 +44,25 @@ const props = defineProps<{
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 errorMessage = ref<string>('');
const mainContentRef = ref<HTMLElement>();
const {
resolverTypes: availableTypes,
fetchResolverTypes: loadResolverTypes,
deleteResolver: deleteResolverFromComposable,
isDeleting,
} = useCredentialResolvers();
const isEditMode = computed(() => !!props.data?.resolverId);
// Type guard to validate and convert resolver config to credential data
@@ -174,14 +176,6 @@ const sidebarItems = computed<IMenuItem[]>(() => [
},
]);
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;
@@ -275,45 +269,25 @@ const onTabSelect = (tabId: string) => {
const deleteResolver = async () => {
if (!props.data?.resolverId) return;
const savedResolverName = resolverName.value;
// Create a resolver object for the composable
const resolver: CredentialResolver = {
id: props.data.resolverId,
name: resolverName.value,
type: resolverType.value,
config: JSON.stringify(resolverConfig.value),
createdAt: new Date(),
updatedAt: new Date(),
};
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',
),
},
);
const deleted = await deleteResolverFromComposable(resolver);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
try {
isDeleting.value = true;
await deleteCredentialResolver(rootStore.restApiContext, props.data.resolverId);
if (deleted) {
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;
modalBus.emit('close');
}
isDeleting.value = false;
modalBus.emit('close');
toast.showMessage({
title: i18n.baseText('credentialResolverEdit.deleteSuccess.title'),
type: 'success',
});
};
onMounted(async () => {
@@ -40,10 +40,7 @@ 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 { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers';
import { ElCol, ElRow, ElSwitch } from 'element-plus';
@@ -64,7 +61,6 @@ 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 }>>([]);
@@ -79,8 +75,14 @@ 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 {
resolvers: credentialResolvers,
fetchResolvers: loadCredentialResolvers,
openCreateModal,
openEditModal,
} = useCredentialResolvers();
const executionTimeout = ref(0);
const maxExecutionTimeout = ref(0);
const timeoutHMS = ref<ITimeoutHMS>({ hours: 0, minutes: 0, seconds: 0 });
@@ -368,30 +370,15 @@ 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;
},
openCreateModal({
onSave: async (resolverId: string) => {
await loadCredentialResolvers();
workflowSettings.value.credentialResolverId = resolverId;
},
});
};
@@ -399,23 +386,13 @@ const handleCreateNewResolver = async () => {
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;
}
},
openEditModal(workflowSettings.value.credentialResolverId, {
onSave: loadCredentialResolvers,
onDelete: async (deletedResolverId: string) => {
await loadCredentialResolvers();
if (workflowSettings.value.credentialResolverId === deletedResolverId) {
workflowSettings.value.credentialResolverId = undefined;
}
},
});
};
@@ -65,6 +65,14 @@ export function useSettingsItems() {
available: canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS),
route: { to: { name: VIEWS.EXTERNAL_SECRETS_SETTINGS } },
},
{
id: 'settings-credential-resolvers',
icon: 'key-round',
label: i18n.baseText('credentialResolver.view.title'),
position: 'top',
available: canUserAccessRouteByName(VIEWS.RESOLVERS),
route: { to: { name: VIEWS.RESOLVERS } },
},
{
id: 'settings-source-control',
icon: 'git-branch',
@@ -64,6 +64,7 @@ export const enum VIEWS {
OAUTH_CONSENT = 'OAuthConsent',
MIGRATION_REPORT = 'MigrationReport',
MIGRATION_RULE_REPORT = 'MigrationRuleReport',
RESOLVERS = 'Resolvers',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
@@ -23,6 +23,7 @@ import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { usePostHog } from '@/app/stores/posthog.store';
import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
@@ -52,6 +53,7 @@ const SettingsPersonalView = async () =>
await import('@/features/core/auth/views/SettingsPersonalView.vue');
const SettingsUsersView = async () =>
await import('@/features/settings/users/views/SettingsUsersView.vue');
const SettingsResolversView = async () => await import('@/features/resolvers/ResolversView.vue');
const SettingsCommunityNodesView = async () =>
await import('@/features/settings/communityNodes/views/SettingsCommunityNodesView.vue');
const SettingsApiView = async () =>
@@ -640,6 +642,30 @@ export const routes: RouteRecordRaw[] = [
},
},
},
{
path: 'resolvers',
name: VIEWS.RESOLVERS,
components: {
settingsView: SettingsResolversView,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
const { check } = useEnvFeatureFlag();
return check.value('DYNAMIC_CREDENTIALS');
},
},
telemetry: {
pageCategory: 'settings',
getProperties() {
return {
feature: 'resolvers',
};
},
},
},
},
{
path: 'project-roles',
components: {
@@ -0,0 +1,341 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import ResolversView from './ResolversView.vue';
import { useUIStore } from '@/app/stores/ui.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM, MODAL_CANCEL } from '@/app/constants';
import * as restApiClient from '@n8n/rest-api-client';
import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types';
const mockConfirm = vi.fn();
const mockShowError = vi.fn();
const mockShowMessage = vi.fn();
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({
confirm: mockConfirm,
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({
showError: mockShowError,
showMessage: mockShowMessage,
}),
}));
vi.mock('@n8n/rest-api-client', async (importOriginal) => {
const actual = await importOriginal<typeof restApiClient>();
return {
...actual,
getCredentialResolvers: vi.fn(),
getCredentialResolverTypes: vi.fn(),
deleteCredentialResolver: vi.fn(),
};
});
const mockResolvers: CredentialResolver[] = [
{
id: 'resolver-1',
name: 'Test Resolver',
type: 'test-type',
config: '{}',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-06-20'),
},
{
id: 'resolver-2',
name: 'Another Resolver',
type: 'another-type',
config: '{}',
createdAt: new Date('2024-03-10'),
updatedAt: new Date('2024-07-15'),
},
];
const mockResolverTypes: CredentialResolverType[] = [
{
name: 'test-type',
displayName: 'Test Type Display Name',
description: 'A test resolver type',
},
{
name: 'another-type',
displayName: 'Another Type Display',
description: 'Another resolver type',
},
];
const renderComponent = createComponentRenderer(ResolversView);
describe('ResolversView', () => {
let pinia: ReturnType<typeof createTestingPinia>;
let uiStore: MockedStore<typeof useUIStore>;
let rootStore: MockedStore<typeof useRootStore>;
beforeEach(() => {
pinia = createTestingPinia();
uiStore = mockedStore(useUIStore);
rootStore = mockedStore(useRootStore);
rootStore.restApiContext = {
baseUrl: 'http://localhost',
pushRef: 'test-ref',
};
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]);
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading state', () => {
it('should show loading skeleton when data is being fetched', async () => {
// Create a promise that never resolves to simulate loading state
vi.mocked(restApiClient.getCredentialResolvers).mockImplementation(
async () => await new Promise(() => {}),
);
const { container } = renderComponent({ pinia });
// The loading component should be visible
await waitFor(() => {
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
});
});
});
describe('Empty state', () => {
it('should display empty state when no resolvers exist', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Resolve dynamic credentials from user identity')).toBeInTheDocument();
});
});
it('should open create modal when clicking "Add new" button in empty state', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]);
const { getByText } = renderComponent({ pinia });
// Wait for empty state to be displayed (after loading completes)
await waitFor(() => {
expect(getByText('Resolve dynamic credentials from user identity')).toBeInTheDocument();
});
// Now the Add Resolver button should be available
await waitFor(() => {
expect(getByText('Add Resolver')).toBeInTheDocument();
});
const addButton = getByText('Add Resolver');
await userEvent.click(addButton);
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: expect.any(Function),
},
});
});
});
describe('List view', () => {
it('should display resolver cards when resolvers exist', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
expect(getByText('Another Resolver')).toBeInTheDocument();
});
});
it('should display resolver type display name from types list', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText(/Test Type Display Name/)).toBeInTheDocument();
});
});
it('should display add button in list view header', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Add Resolver')).toBeInTheDocument();
});
});
it('should open create modal when clicking add button in list view', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
const addButton = getByText('Add Resolver');
await userEvent.click(addButton);
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: expect.any(Function),
},
});
});
});
describe('Actions', () => {
it('should open edit modal when clicking on a resolver card', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
const { getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
await userEvent.click(getByText('Test Resolver'));
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'resolver-1',
onSave: expect.any(Function),
onDelete: expect.any(Function),
},
});
});
it('should show confirmation dialog when deleting a resolver', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]);
mockConfirm.mockResolvedValue(MODAL_CANCEL);
const { container, getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
// Find and click the action toggle button
const actionToggle = container.querySelector('[data-test-id="action-toggle"]');
expect(actionToggle).toBeInTheDocument();
await userEvent.click(actionToggle!);
// Wait for dropdown to appear and click delete
await waitFor(() => {
const deleteOption = getByText('Delete');
expect(deleteOption).toBeInTheDocument();
});
await userEvent.click(getByText('Delete'));
expect(mockConfirm).toHaveBeenCalled();
});
it('should delete resolver when confirmed', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]);
vi.mocked(restApiClient.deleteCredentialResolver).mockResolvedValue(undefined);
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
const { container, getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
// Find and click the action toggle button
const actionToggle = container.querySelector('[data-test-id="action-toggle"]');
await userEvent.click(actionToggle!);
await waitFor(() => {
expect(getByText('Delete')).toBeInTheDocument();
});
await userEvent.click(getByText('Delete'));
await waitFor(() => {
expect(restApiClient.deleteCredentialResolver).toHaveBeenCalledWith(
rootStore.restApiContext,
'resolver-1',
);
});
expect(mockShowMessage).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
});
it('should not delete resolver when cancelled', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]);
mockConfirm.mockResolvedValue(MODAL_CANCEL);
const { container, getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
// Find and click the action toggle button
const actionToggle = container.querySelector('[data-test-id="action-toggle"]');
await userEvent.click(actionToggle!);
await waitFor(() => {
expect(getByText('Delete')).toBeInTheDocument();
});
await userEvent.click(getByText('Delete'));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
});
expect(restApiClient.deleteCredentialResolver).not.toHaveBeenCalled();
});
});
describe('Error handling', () => {
it('should show error toast when delete fails', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([mockResolvers[0]]);
const error = new Error('Delete failed');
vi.mocked(restApiClient.deleteCredentialResolver).mockRejectedValue(error);
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
const { container, getByText } = renderComponent({ pinia });
await waitFor(() => {
expect(getByText('Test Resolver')).toBeInTheDocument();
});
// Find and click the action toggle button
const actionToggle = container.querySelector('[data-test-id="action-toggle"]');
await userEvent.click(actionToggle!);
await waitFor(() => {
expect(getByText('Delete')).toBeInTheDocument();
});
await userEvent.click(getByText('Delete'));
await waitFor(() => {
expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String));
});
});
});
});
@@ -0,0 +1,240 @@
<script setup lang="ts">
import TimeAgo from '@/app/components/TimeAgo.vue';
import type { CredentialResolver } from '@n8n/api-types';
import {
N8nActionBox,
N8nActionToggle,
N8nButton,
N8nCard,
N8nHeading,
N8nIcon,
N8nLink,
N8nLoading2,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import dateformat from 'dateformat';
import { computed, onMounted } from 'vue';
import { useCredentialResolvers } from './composables/useCredentialResolvers';
const i18n = useI18n();
const {
resolvers,
resolverTypes,
isLoading,
fetchResolvers,
fetchResolverTypes,
deleteResolver,
openCreateModal,
openEditModal,
} = useCredentialResolvers();
// TODO: use actual docs link when available
const docsUrl = 'https://docs.n8n.io/';
const RESOLVER_LIST_ITEM_ACTIONS = {
EDIT: 'edit',
DELETE: 'delete',
} as const;
onMounted(async () => {
await Promise.all([fetchResolvers(), fetchResolverTypes()]);
});
const currentYear = new Date().getFullYear().toString();
function getDateFormat(date: Date): string {
return `d mmmm${String(date).startsWith(currentYear) ? '' : ', yyyy'}`;
}
const actions = computed(() => {
return [
{
label: i18n.baseText('credentialResolver.action.edit'),
value: RESOLVER_LIST_ITEM_ACTIONS.EDIT,
},
{
label: i18n.baseText('credentialResolver.action.delete'),
value: RESOLVER_LIST_ITEM_ACTIONS.DELETE,
},
];
});
function createResolver() {
openCreateModal();
}
function editResolver(resolver: CredentialResolver) {
openEditModal(resolver.id);
}
async function handleDeleteResolver(resolver: CredentialResolver) {
const deleted = await deleteResolver(resolver);
if (deleted) {
void fetchResolvers();
}
}
async function onAction(action: string, resolver: CredentialResolver) {
switch (action) {
case RESOLVER_LIST_ITEM_ACTIONS.EDIT:
editResolver(resolver);
break;
case RESOLVER_LIST_ITEM_ACTIONS.DELETE:
await handleDeleteResolver(resolver);
break;
}
}
</script>
<template>
<div :class="$style.container">
<div class="mb-xl" :class="$style.headerContainer">
<div :class="$style.headerTitle">
<N8nHeading tag="h1" size="2xlarge">
{{ i18n.baseText('credentialResolver.view.title') }}
</N8nHeading>
<N8nText v-if="resolvers.length" color="text-base" size="medium">
{{ i18n.baseText('credentialResolver.view.description') }}
{{ i18n.baseText('credentialResolver.view.learnMore') }}
<N8nLink theme="text" :href="docsUrl" size="medium" new-window>
<span :class="$style.link">
{{ i18n.baseText('generic.documentation') }}
<N8nIcon icon="arrow-up-right" />
</span>
</N8nLink>
</N8nText>
</div>
</div>
<N8nLoading2 v-if="isLoading && resolvers.length === 0" :rows="5" :shrink-last="false" />
<div v-else-if="resolvers.length === 0">
<N8nActionBox class="mt-2xl mb-l" description="yes">
<template #description>
<div :class="$style.iconCardContainer">
<div :class="$style.iconCard"><N8nIcon icon="key-round" /></div>
<div :class="$style.iconCard"><N8nIcon icon="split" /></div>
<div :class="$style.iconCard"><N8nIcon icon="user" /></div>
</div>
<N8nHeading tag="h2" size="medium" align="center" class="mb-2xs">
Resolve dynamic credentials from user identity
</N8nHeading>
<div>
{{ i18n.baseText('credentialResolver.view.description') }}
</div>
</template>
<template #additionalContent>
<N8nButton type="highlight" class="mr-2xs" element="a" :href="docsUrl" target="_blank">
Learn more <N8nIcon icon="arrow-up-right" />
</N8nButton>
<N8nButton type="primary" @click="createResolver">
{{ i18n.baseText('credentialResolver.addNew') }}
</N8nButton>
</template>
</N8nActionBox>
</div>
<div v-else>
<div :class="$style.actionBar">
<N8nButton class="ml-auto" type="primary" icon="plus" @click="createResolver">
{{ i18n.baseText('credentialResolver.addNew') }}
</N8nButton>
</div>
<N8nCard
v-for="resolver in resolvers"
:key="resolver.id"
class="mb-2xs"
hoverable
@click.stop="editResolver(resolver)"
>
<template #prepend>
<N8nIcon icon="resolver" color="text-dark" :size="28" />
</template>
<template #header>
<N8nText tag="h2" bold> {{ resolver.name }} </N8nText>
</template>
<div :class="$style.cardDescription">
<N8nText color="text-light" size="small">
{{
resolverTypes.find(({ name }) => name === resolver.type)?.displayName || resolver.type
}}
|
</N8nText>
<N8nText color="text-light" size="small">
{{ i18n.baseText('credentialResolver.item.updated') }}
<TimeAgo :date="resolver.updatedAt.toString()" /> |
</N8nText>
<N8nText color="text-light" size="small">
{{ i18n.baseText('credentialResolver.item.created') }}
{{ dateformat(resolver.createdAt, getDateFormat(resolver.createdAt)) }}
</N8nText>
</div>
<template #append>
<N8nActionToggle :actions="actions" @action="onAction($event, resolver)" />
</template>
</N8nCard>
</div>
</div>
</template>
<style lang="css" module>
.container {
padding-bottom: var(--spacing--xl);
max-width: 702px;
margin: 0 auto;
}
.headerContainer {
display: flex;
justify-content: space-between;
align-items: center;
}
.actionBar {
display: flex;
align-items: center;
margin-bottom: var(--spacing--sm);
}
.headerTitle {
display: flex;
flex-direction: column;
gap: 8px;
}
.iconCardContainer {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: var(--spacing--lg);
}
.iconCard {
width: 40px;
height: 40px;
border: 1px solid var(--color--neutral-100);
display: flex;
justify-content: center;
align-items: center;
background: var(--color--background--light-2);
box-shadow: var(--shadow--dark);
border-radius: var(--radius);
&:nth-child(1) {
transform: rotate(-8deg);
}
&:nth-child(2) {
z-index: 1;
margin-top: -5px;
}
&:nth-child(3) {
transform: rotate(8deg);
}
}
.link {
text-transform: lowercase;
display: inline-flex;
align-items: center;
}
</style>
@@ -0,0 +1,257 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialResolvers } from './useCredentialResolvers';
import { useUIStore } from '@/app/stores/ui.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { mockedStore } from '@/__tests__/utils';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM, MODAL_CANCEL } from '@/app/constants';
import * as restApiClient from '@n8n/rest-api-client';
import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types';
const mockConfirm = vi.fn();
const mockShowError = vi.fn();
const mockShowMessage = vi.fn();
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({
confirm: mockConfirm,
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({
showError: mockShowError,
showMessage: mockShowMessage,
}),
}));
vi.mock('@n8n/rest-api-client', async (importOriginal) => {
const actual = await importOriginal<typeof restApiClient>();
return {
...actual,
getCredentialResolvers: vi.fn(),
getCredentialResolverTypes: vi.fn(),
deleteCredentialResolver: vi.fn(),
};
});
const mockResolvers: CredentialResolver[] = [
{
id: 'resolver-1',
name: 'Test Resolver',
type: 'test-type',
config: '{}',
createdAt: new Date('2024-01-15'),
updatedAt: new Date('2024-06-20'),
},
];
const mockResolverTypes: CredentialResolverType[] = [
{
name: 'test-type',
displayName: 'Test Type Display Name',
description: 'A test resolver type',
},
];
describe('useCredentialResolvers', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
const rootStore = mockedStore(useRootStore);
rootStore.restApiContext = {
baseUrl: 'http://localhost',
pushRef: 'test-ref',
};
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([]);
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('fetchResolvers', () => {
it('should fetch resolvers and update state', async () => {
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue(mockResolvers);
const { resolvers, isLoading, fetchResolvers } = useCredentialResolvers();
expect(resolvers.value).toEqual([]);
expect(isLoading.value).toBe(false);
const fetchPromise = fetchResolvers();
expect(isLoading.value).toBe(true);
await fetchPromise;
expect(resolvers.value).toEqual(mockResolvers);
expect(isLoading.value).toBe(false);
});
it('should show error toast when fetch fails', async () => {
const error = new Error('Fetch failed');
vi.mocked(restApiClient.getCredentialResolvers).mockRejectedValue(error);
const { fetchResolvers } = useCredentialResolvers();
await fetchResolvers();
expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String));
});
});
describe('fetchResolverTypes', () => {
it('should fetch resolver types and update state', async () => {
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue(mockResolverTypes);
const { resolverTypes, fetchResolverTypes } = useCredentialResolvers();
expect(resolverTypes.value).toEqual([]);
await fetchResolverTypes();
expect(resolverTypes.value).toEqual(mockResolverTypes);
});
it('should show error toast when fetch fails', async () => {
const error = new Error('Fetch types failed');
vi.mocked(restApiClient.getCredentialResolverTypes).mockRejectedValue(error);
const { fetchResolverTypes } = useCredentialResolvers();
await fetchResolverTypes();
expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String));
});
});
describe('deleteResolver', () => {
it('should show confirmation dialog before deleting', async () => {
mockConfirm.mockResolvedValue(MODAL_CANCEL);
const { deleteResolver } = useCredentialResolvers();
await deleteResolver(mockResolvers[0]);
expect(mockConfirm).toHaveBeenCalled();
expect(restApiClient.deleteCredentialResolver).not.toHaveBeenCalled();
});
it('should delete resolver when confirmed', async () => {
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
vi.mocked(restApiClient.deleteCredentialResolver).mockResolvedValue(undefined);
const { deleteResolver, isDeleting } = useCredentialResolvers();
const result = await deleteResolver(mockResolvers[0]);
expect(result).toBe(true);
expect(restApiClient.deleteCredentialResolver).toHaveBeenCalledWith(
expect.any(Object),
'resolver-1',
);
expect(mockShowMessage).toHaveBeenCalledWith({
title: expect.any(String),
type: 'success',
});
expect(isDeleting.value).toBe(false);
});
it('should return false when delete is cancelled', async () => {
mockConfirm.mockResolvedValue(MODAL_CANCEL);
const { deleteResolver } = useCredentialResolvers();
const result = await deleteResolver(mockResolvers[0]);
expect(result).toBe(false);
});
it('should show error toast when delete fails', async () => {
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
const error = new Error('Delete failed');
vi.mocked(restApiClient.deleteCredentialResolver).mockRejectedValue(error);
const { deleteResolver } = useCredentialResolvers();
const result = await deleteResolver(mockResolvers[0]);
expect(result).toBe(false);
expect(mockShowError).toHaveBeenCalledWith(error, expect.any(String));
});
});
describe('openCreateModal', () => {
it('should open modal with default callback', () => {
const uiStore = useUIStore();
const { openCreateModal } = useCredentialResolvers();
openCreateModal();
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: expect.any(Function),
},
});
});
it('should open modal with custom callback', () => {
const uiStore = useUIStore();
const customOnSave = vi.fn();
const { openCreateModal } = useCredentialResolvers();
openCreateModal({ onSave: customOnSave });
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: customOnSave,
},
});
});
});
describe('openEditModal', () => {
it('should open modal with resolver id and default callbacks', () => {
const uiStore = useUIStore();
const { openEditModal } = useCredentialResolvers();
openEditModal('resolver-1');
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'resolver-1',
onSave: expect.any(Function),
onDelete: expect.any(Function),
},
});
});
it('should open modal with custom callbacks', () => {
const uiStore = useUIStore();
const customOnSave = vi.fn();
const customOnDelete = vi.fn();
const { openEditModal } = useCredentialResolvers();
openEditModal('resolver-1', { onSave: customOnSave, onDelete: customOnDelete });
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId: 'resolver-1',
onSave: customOnSave,
onDelete: customOnDelete,
},
});
});
});
});
@@ -0,0 +1,122 @@
import { ref } from 'vue';
import type { CredentialResolver, CredentialResolverType } from '@n8n/api-types';
import {
getCredentialResolvers,
getCredentialResolverTypes,
deleteCredentialResolver,
} from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import { useMessage } from '@/app/composables/useMessage';
import { useToast } from '@/app/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { CREDENTIAL_RESOLVER_EDIT_MODAL_KEY, MODAL_CONFIRM } from '@/app/constants';
export interface ModalCallbacks {
onSave?: (resolverId: string) => void | Promise<void>;
onDelete?: (resolverId: string) => void | Promise<void>;
}
export function useCredentialResolvers() {
const rootStore = useRootStore();
const uiStore = useUIStore();
const message = useMessage();
const toast = useToast();
const i18n = useI18n();
const resolvers = ref<CredentialResolver[]>([]);
const resolverTypes = ref<CredentialResolverType[]>([]);
const isLoading = ref(false);
const isDeleting = ref(false);
const fetchResolvers = async (): Promise<void> => {
try {
isLoading.value = true;
resolvers.value = await getCredentialResolvers(rootStore.restApiContext);
} catch (error) {
toast.showError(error, i18n.baseText('workflowSettings.showError.fetchSettings.title'));
} finally {
isLoading.value = false;
}
};
const fetchResolverTypes = async (): Promise<void> => {
try {
resolverTypes.value = await getCredentialResolverTypes(rootStore.restApiContext);
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.loadTypes'));
}
};
const confirmAndDeleteResolver = async (resolver: CredentialResolver): Promise<boolean> => {
const deleteConfirmed = await message.confirm(
i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.message', {
interpolate: { savedResolverName: resolver.name },
}),
i18n.baseText('credentialResolverEdit.confirmMessage.deleteResolver.headline'),
{
confirmButtonText: i18n.baseText(
'credentialResolverEdit.confirmMessage.deleteResolver.confirmButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return false;
}
try {
isDeleting.value = true;
await deleteCredentialResolver(rootStore.restApiContext, resolver.id);
toast.showMessage({
title: i18n.baseText('credentialResolverEdit.deleteSuccess.title'),
type: 'success',
});
return true;
} catch (error) {
toast.showError(error, i18n.baseText('credentialResolverEdit.error.delete'));
return false;
} finally {
isDeleting.value = false;
}
};
const openCreateModal = (callbacks?: ModalCallbacks): void => {
uiStore.openModalWithData({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
onSave: callbacks?.onSave ?? fetchResolvers,
},
});
};
const openEditModal = (resolverId: string, callbacks?: ModalCallbacks): void => {
uiStore.openModalWithData({
name: CREDENTIAL_RESOLVER_EDIT_MODAL_KEY,
data: {
resolverId,
onSave: callbacks?.onSave ?? fetchResolvers,
onDelete: callbacks?.onDelete ?? fetchResolvers,
},
});
};
return {
// State
resolvers,
resolverTypes,
isLoading,
isDeleting,
// Actions
fetchResolvers,
fetchResolverTypes,
deleteResolver: confirmAndDeleteResolver,
// Modal helpers
openCreateModal,
openEditModal,
};
}