diff --git a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts index 73ff3baa2f5..fa61f1e21a1 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.test.ts @@ -63,7 +63,7 @@ vi.mock('@n8n/composables/useToast', () => ({ const mockFetchAllCredentialsForWorkflow = vi.hoisted(() => vi.fn()); vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: vi.fn(() => ({ - fetchAllCredentialsForWorkflow: mockFetchAllCredentialsForWorkflow, + fetchUsableCredentials: mockFetchAllCredentialsForWorkflow, })), })); diff --git a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts index 31c68b340f7..74579012249 100644 --- a/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts +++ b/packages/frontend/editor-ui/src/app/composables/usePostMessageHandler.ts @@ -150,7 +150,7 @@ export function usePostMessageHandler({ currentWorkflowDocumentStore }: PostMess return; } - await credentialsStore.fetchAllCredentialsForWorkflow({ workflowId: data.workflowData.id }); + await credentialsStore.fetchUsableCredentials({ workflowId: data.workflowData.id }); const wfId = workflowsStore.workflowId; if (wfId) { diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts index 6335f7dac44..c13b13a6adc 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts @@ -113,7 +113,7 @@ export function useWorkflowInitialization() { options = { projectId }; } - await credentialsStore.fetchAllCredentialsForWorkflow(options); + await credentialsStore.fetchUsableCredentials(options); } /** diff --git a/packages/frontend/editor-ui/src/app/views/NodeView.vue b/packages/frontend/editor-ui/src/app/views/NodeView.vue index fd869c5192c..e09d2b2f5e4 100644 --- a/packages/frontend/editor-ui/src/app/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/app/views/NodeView.vue @@ -830,7 +830,7 @@ async function loadCredentials() { options = { projectId }; } - await credentialsStore.fetchAllCredentialsForWorkflow(options); + await credentialsStore.fetchUsableCredentials(options); } /** diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 51d346e17cb..6ba45abfdaa 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -33,13 +33,13 @@ let createObjectURLSpy: ReturnType | undefined; let revokeObjectURLSpy: ReturnType | undefined; let anchorClickSpy: ReturnType | undefined; const { - fetchAllCredentialsForWorkflowMock, + fetchUsableCredentialsMock, fetchAllCredentialsMock, fetchCredentialTypesMock, setCredentialsMock, agentPermissionsMock, } = vi.hoisted(() => ({ - fetchAllCredentialsForWorkflowMock: vi.fn().mockResolvedValue(undefined), + fetchUsableCredentialsMock: vi.fn().mockResolvedValue(undefined), fetchAllCredentialsMock: vi.fn().mockResolvedValue(undefined), fetchCredentialTypesMock: vi.fn().mockResolvedValue(undefined), setCredentialsMock: vi.fn(), @@ -88,7 +88,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({ allCredentials: [], getCredentialsByType: () => [], fetchAllCredentials: fetchAllCredentialsMock, - fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock, + fetchUsableCredentials: fetchUsableCredentialsMock, fetchCredentialTypes: fetchCredentialTypesMock, setCredentials: setCredentialsMock, }), @@ -783,7 +783,7 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => { await renderView(); expect(setCredentialsMock).toHaveBeenCalledWith([]); - expect(fetchAllCredentialsForWorkflowMock).toHaveBeenCalledWith({ projectId: 'p1' }); + expect(fetchUsableCredentialsMock).toHaveBeenCalledWith({ projectId: 'p1' }); expect(fetchAllCredentialsMock).not.toHaveBeenCalled(); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelsSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelsSection.test.ts index 51aefa88f3b..e0f818f210a 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelsSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChannelsSection.test.ts @@ -6,7 +6,7 @@ import AgentChannelsSection from '../components/AgentChannelsSection.vue'; vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ setCredentials: vi.fn(), - fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue([]), + fetchUsableCredentials: vi.fn().mockResolvedValue([]), }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts index 936cfedc2c3..58486e70c7f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVectorStoresModal.test.ts @@ -7,7 +7,7 @@ import AgentVectorStoresModal from '../components/AgentVectorStoresModal.vue'; const closeModalMock = vi.fn(); const openNewCredentialMock = vi.fn(); -const fetchAllCredentialsForWorkflowMock = vi.fn(); +const fetchUsableCredentialsMock = vi.fn(); const testAgentVectorStoreMock = vi.fn(); const showMessageMock = vi.fn(); const showErrorMock = vi.fn(); @@ -45,7 +45,7 @@ vi.mock('@/features/credentials/credentials.constants', () => ({ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ - fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock, + fetchUsableCredentials: fetchUsableCredentialsMock, getCredentialTypeByName: () => undefined, }), })); @@ -163,7 +163,7 @@ const expectedVectorStore = { describe('AgentVectorStoresModal', () => { beforeEach(() => { vi.clearAllMocks(); - fetchAllCredentialsForWorkflowMock.mockResolvedValue([qdrantCredential, openAiCredential]); + fetchUsableCredentialsMock.mockResolvedValue([qdrantCredential, openAiCredential]); }); it('lists all four vector store providers with a Connect button each', async () => { @@ -353,7 +353,7 @@ describe('AgentVectorStoresModal', () => { it('clears provider-specific fields when going back to the provider picker', async () => { const pineconeCredential = { id: 'pinecone-cred-1', name: 'My Pinecone', type: 'pineconeApi' }; - fetchAllCredentialsForWorkflowMock.mockResolvedValue([ + fetchUsableCredentialsMock.mockResolvedValue([ pineconeCredential, qdrantCredential, openAiCredential, diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts index 4ecdf3a38f7..2cbd79a549f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChannelSetup.test.ts @@ -30,7 +30,7 @@ vi.mock('@/app/stores/ui.store', () => ({ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ setCredentials: vi.fn(), - fetchAllCredentialsForWorkflow: fetchCredentials, + fetchUsableCredentials: fetchCredentials, getCredentialTypeByName: vi.fn(), }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentModelCredentials.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentModelCredentials.test.ts index 5c3e53b4b79..d9ce0e085c3 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentModelCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentModelCredentials.test.ts @@ -26,7 +26,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({ .flat() .find((c) => c.id === id), fetchCredentialTypes: vi.fn().mockResolvedValue(undefined), - fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue(undefined), + fetchUsableCredentials: vi.fn().mockResolvedValue(undefined), }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts index fa2db2eeca2..37b1af0c41c 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.test.ts @@ -28,7 +28,7 @@ vi.mock('@/app/stores/ui.store', () => ({ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ setCredentials: vi.fn(), - fetchAllCredentialsForWorkflow: mocks.fetchCredentials, + fetchUsableCredentials: mocks.fetchCredentials, deleteCredential: vi.fn(), }), })); diff --git a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts index d47fe4e2509..3dd0e8dcc9d 100644 --- a/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts +++ b/packages/frontend/editor-ui/src/features/agents/channels/slack/useSlackChannelRuntime.ts @@ -210,7 +210,7 @@ export function useSlackChannelRuntime(context: AgentChannelRuntimeContext): Sla try { credentialsStore.setCredentials([]); - const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({ + const credentials = await credentialsStore.fetchUsableCredentials({ projectId: context.projectId.value, }); const credential = credentials.find( diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelsSection.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelsSection.vue index 5641da9a39b..0dd56331739 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChannelsSection.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChannelsSection.vue @@ -110,7 +110,7 @@ async function loadChannelDetails() { try { credentialsStore.setCredentials([]); - const credentials = await credentialsStore.fetchAllCredentialsForWorkflow({ + const credentials = await credentialsStore.fetchUsableCredentials({ projectId: props.projectId, }); credentialNamesById.value = Object.fromEntries( diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentVectorStoresModal.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentVectorStoresModal.vue index 43ac584f121..c250fcd201d 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentVectorStoresModal.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentVectorStoresModal.vue @@ -231,7 +231,7 @@ function onEmbeddingModelUpdate(model: string) { async function loadCredentials() { credentialsLoading.value = true; try { - const allCredentials = await credentialsStore.fetchAllCredentialsForWorkflow({ + const allCredentials = await credentialsStore.fetchUsableCredentials({ projectId: props.data.projectId, }); const types = new Set(); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts index d16a9c4e5cb..183719b7e73 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChannelSetup.ts @@ -98,7 +98,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) { credentialsLoading.value = true; try { credentialsStore.setCredentials([]); - const allCredentials = await credentialsStore.fetchAllCredentialsForWorkflow({ + const allCredentials = await credentialsStore.fetchUsableCredentials({ projectId: projectId.value, }); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentModelCredentials.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentModelCredentials.ts index fc8738906b2..2c34462c78d 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentModelCredentials.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentModelCredentials.ts @@ -120,7 +120,7 @@ export function useAgentModelCredentials(userId: string, projectId: MaybeRefOrGe await Promise.all([ credentialsStore.fetchCredentialTypes(false), - credentialsStore.fetchAllCredentialsForWorkflow({ projectId: id }), + credentialsStore.fetchUsableCredentials({ projectId: id }), ]); isInitialized.value = true; diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index efd52b6a457..34f0fcc8e11 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -1503,7 +1503,7 @@ async function initialize({ preserveState = false }: { preserveState?: boolean } // credentials the current user can use in this project context. credentialsStore.setCredentials([]); await Promise.all([ - credentialsStore.fetchAllCredentialsForWorkflow({ projectId: targetProjectId }), + credentialsStore.fetchUsableCredentials({ projectId: targetProjectId }), credentialsStore.fetchCredentialTypes(false), ]).catch(() => undefined); if (!isCurrentInitialization()) return; diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts index af84128b3b4..fe10cb6c5ef 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.test.ts @@ -41,7 +41,7 @@ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ fetchCredentialTypes: vi.fn().mockResolvedValue(undefined), fetchAllCredentials: vi.fn().mockResolvedValue(undefined), - fetchAllCredentialsForWorkflow: vi.fn().mockResolvedValue([]), + fetchUsableCredentials: vi.fn().mockResolvedValue([]), getCredentialById: vi.fn().mockReturnValue(undefined), getCredentialsByType: vi.fn().mockReturnValue([]), getCredentialTypeByName: vi.fn().mockReturnValue(undefined), diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts index 9aff294bb05..a17e2faed45 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/composables/useChatCredentials.ts @@ -103,7 +103,7 @@ export function useChatCredentials(userId: string) { if (personalProject) { await Promise.all([ credentialsStore.fetchCredentialTypes(false), - credentialsStore.fetchAllCredentialsForWorkflow({ projectId: personalProject.id }), + credentialsStore.fetchUsableCredentials({ projectId: personalProject.id }), ]); isInitialized.value = true; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts index f5935ee9daa..84fedaab2d5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts @@ -190,7 +190,7 @@ describe('InstanceAiCredentialSetup', () => { const credentialsStore = useCredentialsStore(); vi.spyOn(credentialsStore, 'fetchAllCredentials').mockResolvedValue([]); - vi.spyOn(credentialsStore, 'fetchAllCredentialsForWorkflow').mockResolvedValue([]); + vi.spyOn(credentialsStore, 'fetchUsableCredentials').mockResolvedValue([]); vi.spyOn(credentialsStore, 'fetchCredentialTypes').mockResolvedValue(undefined); // The card renders the NodeCredentials picker when the store has a usable // credential of the type; default to one so the picker-based tests render it. @@ -254,7 +254,7 @@ describe('InstanceAiCredentialSetup', () => { }); await nextTick(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'project-team-1', }); expect(credentialsStore.fetchAllCredentials).not.toHaveBeenCalled(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue index 35e0639240c..917575e6e07 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiCredentialSetup.vue @@ -236,7 +236,7 @@ onMounted(async () => { try { await Promise.all([ props.projectId - ? credentialsStore.fetchAllCredentialsForWorkflow({ projectId: props.projectId }) + ? credentialsStore.fetchUsableCredentials({ projectId: props.projectId }) : credentialsStore.fetchAllCredentials(), credentialsStore.fetchCredentialTypes(false), ]); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.test.ts index 21e1b332b44..65111b12b9e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.test.ts @@ -16,7 +16,7 @@ describe('useWorkflowSetupBootstrap', () => { credentialsStore = mockedStore(useCredentialsStore); nodeTypesStore = mockedStore(useNodeTypesStore); - credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]); + credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]); credentialsStore.fetchCredentialTypes = vi.fn().mockResolvedValue(undefined); nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined); }); @@ -25,7 +25,7 @@ describe('useWorkflowSetupBootstrap', () => { const { bootstrap } = useWorkflowSetupBootstrap(ref(undefined)); await expect(bootstrap()).rejects.toThrow('useWorkflowSetupBootstrap: workflowId is required'); - expect(credentialsStore.fetchAllCredentialsForWorkflow).not.toHaveBeenCalled(); + expect(credentialsStore.fetchUsableCredentials).not.toHaveBeenCalled(); }); it('fetches workflow-scoped credentials and flips isReady after success', async () => { @@ -35,7 +35,7 @@ describe('useWorkflowSetupBootstrap', () => { await bootstrap(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ workflowId: 'workflow-1', }); expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalled(); @@ -44,9 +44,7 @@ describe('useWorkflowSetupBootstrap', () => { }); it('flips isReady to true even when one of the parallel calls fails', async () => { - credentialsStore.fetchAllCredentialsForWorkflow = vi - .fn() - .mockRejectedValue(new Error('network')); + credentialsStore.fetchUsableCredentials = vi.fn().mockRejectedValue(new Error('network')); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { isReady, bootstrap } = useWorkflowSetupBootstrap(ref('workflow-1')); @@ -66,7 +64,7 @@ describe('useWorkflowSetupBootstrap', () => { workflowId.value = 'workflow-2'; await bootstrap(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ workflowId: 'workflow-2', }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.ts index 4a1d74494c5..25cd3fc9686 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/composables/useWorkflowSetupBootstrap.ts @@ -19,7 +19,7 @@ export function useWorkflowSetupBootstrap(workflowId: Ref): } try { await Promise.all([ - credentialsStore.fetchAllCredentialsForWorkflow({ workflowId: wid }), + credentialsStore.fetchUsableCredentials({ workflowId: wid }), credentialsStore.fetchCredentialTypes(false), nodeTypesStore.loadNodeTypesIfNotLoaded(), ]); diff --git a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts index fb8a5919200..1abfa5a15a7 100644 --- a/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/shared/components/ChannelSetupCard.test.ts @@ -99,7 +99,7 @@ vi.mock('@/features/agents/composables/useAgentApi', () => ({ vi.mock('@/features/credentials/credentials.store', () => ({ useCredentialsStore: () => ({ setCredentials: vi.fn(), - fetchAllCredentialsForWorkflow: mocks.fetchCredentials, + fetchUsableCredentials: mocks.fetchCredentials, deleteCredential: vi.fn(), }), })); diff --git a/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts b/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts index aeb49301b50..e9e725945e1 100644 --- a/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/__tests__/credentials.store.test.ts @@ -1,6 +1,7 @@ import { createPinia, setActivePinia } from 'pinia'; import { mock } from 'vitest-mock-extended'; import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow'; +import type { INodeUi } from '@/Interface'; import type { ICredentialsResponse } from '../credentials.types'; import * as credentialsApi from '../credentials.api'; import { useCredentialsStore } from '../credentials.store'; @@ -220,6 +221,222 @@ describe('credentials.store', () => { }); }); + describe('fetchUsableCredentials', () => { + const credential = ( + overrides: Partial & Pick, + ): ICredentialsResponse => + mock({ + name: `Credential ${overrides.id}`, + type: 'httpBasicAuth', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }); + + const inScope = credential({ id: 'in-scope', name: 'Project credential' }); + const outOfScope = credential({ id: 'out-of-scope', name: 'Personal credential' }); + + it('populates the usable slice and flips the fetched flag', async () => { + const store = useCredentialsStore(); + expect(store.hasFetchedUsableCredentials).toBe(false); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + expect(credentialsApi.getUsableCredentials).toHaveBeenCalledWith( + mockRootStore.restApiContext, + { workflowId: 'wf-1' }, + ); + expect(store.hasFetchedUsableCredentials).toBe(true); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]); + // The flat map keeps its existing replace semantics. + expect(store.allCredentials).toEqual([inScope]); + }); + + it('reads an unfetched slice as empty rather than falling back to the flat map', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([outOfScope]); + await store.fetchAllCredentials(); + + expect(store.allCredentials).toEqual([outOfScope]); + expect(store.hasFetchedUsableCredentials).toBe(false); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([]); + }); + + it('keeps the usable slice when a later unscoped fetch widens the flat map', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([inScope, outOfScope]); + + await store.fetchUsableCredentials({ projectId: 'project-1' }); + await store.fetchAllCredentials(); + + expect(store.allCredentials).toHaveLength(2); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]); + }); + + it('keeps the usable slice when the unscoped fetch resolved first', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue([inScope, outOfScope]); + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + + await store.fetchAllCredentials(); + await store.fetchUsableCredentials({ projectId: 'project-1' }); + + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]); + }); + + it('returns an empty list for a type with no usable credentials', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + expect(store.getUsableCredentialByType('unknownType')).toEqual([]); + }); + + it('never yields undefined entries for a node whose types are only partly usable', async () => { + const store = useCredentialsStore(); + + mockNodeTypesStore.getNodeType.mockReturnValue( + mock({ + credentials: [{ name: 'httpBasicAuth' }, { name: 'oAuth2Api' }], + }), + ); + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + const credentials = store.allUsableCredentialsForNode( + mock({ type: 'n8n-nodes-base.httpRequest', typeVersion: 1 }), + ); + + expect(credentials).toEqual([inScope]); + }); + + it('drops the slice while a different scope is in flight', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + let resolveSecond: (credentials: ICredentialsResponse[]) => void = () => {}; + vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValue( + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + const pending = store.fetchUsableCredentials({ workflowId: 'wf-2' }); + + // The previous workflow's credentials must not stand in for the new scope. + expect(store.hasFetchedUsableCredentials).toBe(false); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([]); + + resolveSecond([outOfScope]); + await pending; + + expect(store.hasFetchedUsableCredentials).toBe(true); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([outOfScope]); + }); + + it('ignores a response for a scope that is no longer active', async () => { + const store = useCredentialsStore(); + + let resolveFirst: (credentials: ICredentialsResponse[]) => void = () => {}; + vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const stale = store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-2' }); + + resolveFirst([outOfScope]); + await stale; + + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]); + }); + + it('ignores an older response for the scope already loaded', async () => { + const store = useCredentialsStore(); + + // A refresh — the one a quick connect triggers, say — can overtake a fetch the + // same scope started earlier; the newest answer has to win. + let resolveFirst: (credentials: ICredentialsResponse[]) => void = () => {}; + vi.spyOn(credentialsApi, 'getUsableCredentials').mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const stale = store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope, outOfScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + resolveFirst([inScope]); + await stale; + + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([outOfScope, inScope]); + }); + + it('keeps the slice when the same scope is fetched again', async () => { + const store = useCredentialsStore(); + + vi.spyOn(credentialsApi, 'getUsableCredentials').mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + const pending = store.fetchUsableCredentials({ workflowId: 'wf-1' }); + + expect(store.hasFetchedUsableCredentials).toBe(true); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope]); + await pending; + }); + }); + + describe('refreshUsableCredentials', () => { + const inScope = mock({ + id: 'in-scope', + name: 'A project credential', + type: 'httpBasicAuth', + }); + const connected = mock({ + id: 'connected', + name: 'B just connected', + type: 'httpBasicAuth', + }); + + it('re-reads the scope the slice was last fetched for', async () => { + const store = useCredentialsStore(); + + const fetchSpy = vi + .spyOn(credentialsApi, 'getUsableCredentials') + .mockResolvedValue([inScope]); + await store.fetchUsableCredentials({ projectId: 'project-1' }); + + fetchSpy.mockResolvedValue([inScope, connected]); + await store.refreshUsableCredentials(); + + expect(fetchSpy).toHaveBeenLastCalledWith(mockRootStore.restApiContext, { + projectId: 'project-1', + }); + expect(store.getUsableCredentialByType('httpBasicAuth')).toEqual([inScope, connected]); + }); + + it('does nothing when no scoped fetch has happened', async () => { + const store = useCredentialsStore(); + + const fetchSpy = vi.spyOn(credentialsApi, 'getUsableCredentials'); + await store.refreshUsableCredentials(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(store.hasFetchedUsableCredentials).toBe(false); + }); + }); + describe('createNewCredential', () => { it('should pass isGlobal parameter to API when creating credential', async () => { const store = useCredentialsStore(); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts index e44bc624b09..80ac85be3a6 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts @@ -1,5 +1,5 @@ -import { shallowRef, ref, computed, nextTick } from 'vue'; -import { describe, it, vi, beforeEach } from 'vitest'; +import { shallowRef, ref, computed, nextTick, watchSyncEffect } from 'vue'; +import { describe, it, vi, beforeEach, afterEach } from 'vitest'; import { screen, within, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { createTestingPinia } from '@pinia/testing'; @@ -177,6 +177,7 @@ describe('NodeCredentials', () => { typeof shallowRef | null> >; let renderComponent: ReturnType; + let stopCredentialsMirror: () => void; beforeEach(() => { vi.clearAllMocks(); @@ -209,7 +210,18 @@ describe('NodeCredentials', () => { credentialsStore = mockedStore(useCredentialsStore); // Component triggers this on mount; avoid a real XHR with stubActions: false. - credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]); + credentialsStore.fetchUsableCredentials = vi + .fn() + .mockImplementation(async () => Object.values(credentialsStore.usableCredentials)); + + // The picker reads `usableCredentials`, the slice only the scoped fetch writes. + // Tests seed the flat map, and by the time an NDV mounts in production the + // scoped fetch has already run (useWorkflowInitialization), so mirror the seed + // into the slice. Tests that need the two to diverge stop the mirror first. + stopCredentialsMirror = watchSyncEffect(() => { + credentialsStore.usableCredentials = { ...credentialsStore.state.credentials }; + }); + credentialsStore.hasFetchedUsableCredentials = true; ndvStore = mockedStore(useNDVStore, createWorkflowDocumentId('1')); uiStore = mockedStore(useUIStore); @@ -233,6 +245,10 @@ describe('NodeCredentials', () => { }; }); + afterEach(() => { + stopCredentialsMirror?.(); + }); + it('should display available credentials in the dropdown', async () => { ndvStore.activeNode = httpNode; credentialsStore.state.credentials = { @@ -248,6 +264,27 @@ describe('NodeCredentials', () => { expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); }); + it('should not offer a credential the scoped fetch left out of the usable slice', async () => { + // An unscoped fetchAllCredentials from anywhere in the app can fill the flat + // map with credentials from other projects while the NDV is open (IAM-1241). + stopCredentialsMirror(); + ndvStore.activeNode = httpNode; + credentialsStore.state.credentials = { + c8vqdPpPClh4TgIO: createCredential(), + 'personal-cred': createCredential({ id: 'personal-cred', name: 'Personal OpenAi account' }), + }; + credentialsStore.usableCredentials = { + c8vqdPpPClh4TgIO: createCredential(), + }; + + renderComponent(); + + await userEvent.click(screen.getByTestId('node-credentials-select')); + + expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); + expect(screen.queryByText('Personal OpenAi account')).not.toBeInTheDocument(); + }); + it('replaces the type-derived field label when credentialsFieldLabel is set', () => { ndvStore.activeNode = httpNode; credentialsStore.state.credentials = { @@ -287,7 +324,7 @@ describe('NodeCredentials', () => { renderComponent(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ workflowId: '1', }); }); @@ -301,7 +338,7 @@ describe('NodeCredentials', () => { renderComponent({ props: { skipCredentialsFetch: true } }); - expect(credentialsStore.fetchAllCredentialsForWorkflow).not.toHaveBeenCalled(); + expect(credentialsStore.fetchUsableCredentials).not.toHaveBeenCalled(); }); it('should fetch credentials scoped to the project for an unsaved workflow', () => { @@ -312,7 +349,7 @@ describe('NodeCredentials', () => { renderComponent(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'project-1', }); }); @@ -326,7 +363,7 @@ describe('NodeCredentials', () => { renderComponent(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'personal-project', }); }); @@ -2411,6 +2448,46 @@ describe('NodeCredentials', () => { }); }); + it('should not auto-enable the gateway before the scoped fetch has resolved', async () => { + // An unfetched slice reads as "no credentials" — acting on it would switch a + // node that has a perfectly good credential onto n8n credits (IAM-1241). + stopCredentialsMirror(); + const ownCred = { + id: 'cred-1', + name: 'My Google Key', + type: 'googlePalmApi', + isManaged: false, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + }; + credentialsStore.hasFetchedUsableCredentials = false; + credentialsStore.usableCredentials = {}; + credentialsStore.getCredentialById = vi.fn().mockReturnValue(ownCred); + const nodeWithAction: INodeUi = { + ...googleAiNode, + parameters: { resource: 'chat', operation: 'message' }, + }; + ndvStore.activeNode = nodeWithAction; + + const { emitted } = renderComponent({ + props: { node: nodeWithAction, overrideCredType: 'googlePalmApi' }, + }); + + expect(emitted('credentialSelected')).toBeFalsy(); + + // Once the scope lands the user's own credential is selected instead. + credentialsStore.usableCredentials = { 'cred-1': ownCred }; + credentialsStore.hasFetchedUsableCredentials = true; + await nextTick(); + + const payload = ((emitted('credentialSelected')?.[0] as unknown[]) ?? [])[0] as { + properties: { credentials: Record }; + }; + expect(payload.properties.credentials['googlePalmApi']).toEqual( + expect.objectContaining({ id: 'cred-1' }), + ); + }); + it('should auto-enable gateway credential on mount for a directly-supported single-cred node in the NDV (show-all, no override)', () => { // Real NDV config: show-all is true and no overrideCredType. Regression guard // for the sibling-fallback change not clobbering directly-supported types. diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue index 2009581786f..3dd8e59fed0 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue @@ -33,7 +33,7 @@ import { useI18n } from '@n8n/i18n'; import { useTelemetry } from '@n8n/composables/useTelemetry'; import { ChatHubToolContextKey, CREDENTIAL_ONLY_NODE_PREFIX } from '@/app/constants'; import { ndvEventBus } from '@/features/ndv/shared/ndv.eventBus'; -import { useCredentialsStore } from '../credentials.store'; +import { useCredentialsStore, type CredentialFetchScope } from '../credentials.store'; import { useQuickConnect } from '../quickConnect/composables/useQuickConnect'; import { useCredentialOAuth } from '../composables/useCredentialOAuth'; import QuickConnectButton from '../quickConnect/components/QuickConnectButton.vue'; @@ -374,6 +374,15 @@ watch( { immediate: true, deep: true }, ); +// Started here rather than in `onMounted`: the request drops the slice held for a +// different workflow or project synchronously, and that has to happen before the +// watchers below and the first render read it — otherwise the picker's opening +// frame lists the previously opened scope's credentials. +const initialFetchScope = props.skipCredentialsFetch ? undefined : getCredentialFetchScope(); +if (initialFetchScope) { + void credentialsStore.fetchUsableCredentials(initialFetchScope); +} + let hasEvaluatedCredentials = false; // Select most recent credential by default @@ -382,6 +391,10 @@ watch( (types) => { if (props.skipAutoSelect) return; if (types.length === 0) return; + // Before the scoped fetch lands there are no options to pick from, which would + // read as "no credentials exist" and auto-enable the AI Gateway below. The + // watcher re-fires once the fetch populates the slice. + if (!credentialsStore.hasFetchedUsableCredentials) return; const isInitialEvaluation = !hasEvaluatedCredentials; hasEvaluatedCredentials = true; @@ -432,7 +445,7 @@ watch( { immediate: true }, ); -function getCredentialFetchScope(): { workflowId: string } | { projectId: string } | undefined { +function getCredentialFetchScope(): CredentialFetchScope | undefined { const workflowId = workflowDocumentStore?.value.workflowId; if (workflowId && !workflowsStore.isNewWorkflow) { return { workflowId }; @@ -467,6 +480,18 @@ onMounted(() => { } } + // Let the server decide whether the mutated credential is usable here rather + // than optimistically inserting it — a credential edited from the command bar + // may well belong to another project. + const refetchScope = getCredentialFetchScope(); + if (refetchScope) { + try { + await credentialsStore.fetchUsableCredentials(refetchScope); + } catch { + // Fall through with whatever the store already holds. + } + } + const current = selected.value[credentialType]; let credentialsOfType: ICredentialsResponse[] = []; if (props.showAll) { @@ -513,11 +538,6 @@ onMounted(() => { ndvEventBus.on('credential.createNew', onCreateAndAssignNewCredential); - const scope = props.skipCredentialsFetch ? undefined : getCredentialFetchScope(); - if (scope) { - void credentialsStore.fetchAllCredentialsForWorkflow(scope); - } - void aiGateway.fetchConfig(); void aiGateway.fetchWallet(); diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.test.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.test.ts new file mode 100644 index 00000000000..c24f49c9739 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.test.ts @@ -0,0 +1,98 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { mock } from 'vitest-mock-extended'; + +import type { ICredentialsResponse } from '../credentials.types'; +import * as credentialsApi from '../credentials.api'; +import { useCredentialsStore } from '../credentials.store'; +import { useCredentialOAuth } from './useCredentialOAuth'; + +const mockRootStore = { + restApiContext: { baseUrl: 'http://localhost:5678', sessionId: 'test-session' }, + baseUrl: 'http://localhost:5678', + urlBaseEditor: 'http://localhost:5678', +}; + +const { useRootStore } = vi.hoisted(() => ({ + useRootStore: vi.fn(() => mockRootStore), +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore })); + +vi.mock('@n8n/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track: vi.fn() }), +})); + +vi.mock('@n8n/composables/useToast', () => ({ + useToast: () => ({ showError: vi.fn(), showMessage: vi.fn() }), +})); + +vi.mock('./oauthCallback', () => ({ + getTrustedOAuthOrigins: () => ['http://localhost:5678'], + hasOAuthTokenData: vi.fn(() => false), + waitForOAuthCallback: vi.fn(async () => 'success'), +})); + +vi.mock('../credentials.api'); +vi.mock('../credentials.ee.api'); + +const credential = (overrides: Partial = {}): ICredentialsResponse => + mock({ + id: 'new-cred', + name: 'A Google account', + type: 'oAuth2Api', + isResolvable: false, + ...overrides, + }); + +describe('useCredentialOAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + + vi.stubGlobal( + 'open', + vi.fn(() => ({ location: { href: '' }, close: vi.fn() })), + ); + vi.mocked(credentialsApi.oAuth2CredentialAuthorize).mockResolvedValue( + 'https://example.com/authorize', + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('authorizeNewCredential', () => { + it('makes the connected credential available to the scoped picker', async () => { + const store = useCredentialsStore(); + const connected = credential(); + + // The picker starts on a scope that does not know the credential yet. + vi.mocked(credentialsApi.getUsableCredentials).mockResolvedValue([]); + await store.fetchUsableCredentials({ workflowId: 'wf-1' }); + expect(store.getUsableCredentialByType('oAuth2Api')).toEqual([]); + + vi.mocked(credentialsApi.getUsableCredentials).mockResolvedValue([connected]); + const success = await useCredentialOAuth().authorizeNewCredential(connected); + + expect(success).toBe(true); + expect(credentialsApi.getUsableCredentials).toHaveBeenLastCalledWith( + mockRootStore.restApiContext, + { workflowId: 'wf-1' }, + ); + expect(store.getUsableCredentialByType('oAuth2Api')).toEqual([connected]); + }); + + it('leaves the slice untouched when no scoped fetch has happened', async () => { + const store = useCredentialsStore(); + const connected = credential(); + + await useCredentialOAuth().authorizeNewCredential(connected); + + expect(credentialsApi.getUsableCredentials).not.toHaveBeenCalled(); + expect(store.hasFetchedUsableCredentials).toBe(false); + // The flat map still learns about the credential, as it did before. + expect(store.getCredentialById('new-cred')?.id).toBe('new-cred'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts index e90031f1c09..08bfab73794 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOAuth.ts @@ -318,6 +318,17 @@ export function useCredentialOAuth() { * Authorize a credential that was just created. Keeps it out of the store * until OAuth succeeds and removes it when authorization is not completed. */ + /** + * Publishes a credential the user just connected. `upsertCredential` only reaches + * the flat map, so the picker — which reads the scope-narrowed slice — would not + * offer the credential until the next scoped fetch. Ask the server rather than + * inserting locally: only it can say whether the credential is usable here. + */ + async function publishConnectedCredential(credential: ICredentialsResponse): Promise { + credentialsStore.upsertCredential(credential); + await credentialsStore.refreshUsableCredentials(); + } + async function authorizeNewCredential( credential: ICredentialsResponse, options: OAuthAuthorizationOptions = {}, @@ -329,7 +340,7 @@ export function useCredentialOAuth() { try { success = await authorize(credential, controller.signal, options); if (success) { - credentialsStore.upsertCredential(credential); + await publishConnectedCredential(credential); } return success; } finally { @@ -427,7 +438,7 @@ export function useCredentialOAuth() { telemetry.track('User saved credentials', trackProperties); if (success) { - credentialsStore.upsertCredential(credential); + await publishConnectedCredential(credential); return credential; } diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.test.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.test.ts index 797c06630f2..874e53ed9c3 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.test.ts @@ -119,7 +119,9 @@ describe('useNodeCredentialOptions', () => { slackApi: slackApiType, slackOAuth2Api: slackOAuth2ApiType, }; - credentialsStore.state.credentials = { + // The picker reads the slice the scoped fetch writes, not the flat map. + credentialsStore.hasFetchedUsableCredentials = true; + credentialsStore.usableCredentials = { 'token-cred': createCredential({ id: 'token-cred', name: 'Team Slack Token', @@ -196,7 +198,7 @@ describe('useNodeCredentialOptions', () => { }) as INodeTypeDescription, ); credentialsStore.state.credentialTypes.httpBasicAuth = httpBasicAuth; - credentialsStore.state.credentials['basic-auth-cred'] = createCredential({ + credentialsStore.usableCredentials['basic-auth-cred'] = createCredential({ id: 'basic-auth-cred', name: 'Webhook Basic Auth', type: 'httpBasicAuth', @@ -218,7 +220,7 @@ describe('useNodeCredentialOptions', () => { }); it('excludes instance credentials from node options', () => { - credentialsStore.state.credentials['instance-cred'] = createCredential({ + credentialsStore.usableCredentials['instance-cred'] = createCredential({ id: 'instance-cred', name: 'Instance Slack Token', type: 'slackApi', @@ -232,6 +234,60 @@ describe('useNodeCredentialOptions', () => { ).not.toContain('instance-cred'); }); + it('ignores credentials the scoped fetch left out, even when the flat map holds them', () => { + credentialsStore.state.credentials = { + 'personal-cred': createCredential({ + id: 'personal-cred', + name: 'Personal Slack Token', + type: 'slackApi', + }), + }; + + const { credentialTypesNodeDescriptionDisplayed } = setupOptions('slackApi'); + + expect( + credentialTypesNodeDescriptionDisplayed.value[0].options.map((option) => option.id), + ).toEqual(['token-cred']); + }); + + it('reports a configured out-of-scope credential as missing once the scope is fetched', () => { + const nodeWithForeignCredential = computed( + () => + ({ + ...slackNode, + credentials: { slackApi: { id: 'personal-cred', name: 'Personal Slack Token' } }, + }) as INodeUi, + ); + const { isCredentialExisting } = useNodeCredentialOptions( + nodeWithForeignCredential, + computed(() => slackNodeType), + 'slackApi', + ); + + expect(isCredentialExisting(slackNodeType.credentials[0])).toBe(false); + }); + + it('treats a configured credential as existing until the scope has been fetched', () => { + // Reporting it missing before the first fetch flashes "credential unavailable" + // and raises an NDV credential issue that isn't one. + credentialsStore.hasFetchedUsableCredentials = false; + credentialsStore.usableCredentials = {}; + const nodeWithCredential = computed( + () => + ({ + ...slackNode, + credentials: { slackApi: { id: 'token-cred', name: 'Team Slack Token' } }, + }) as INodeUi, + ); + const { isCredentialExisting } = useNodeCredentialOptions( + nodeWithCredential, + computed(() => slackNodeType), + 'slackApi', + ); + + expect(isCredentialExisting(slackNodeType.credentials[0])).toBe(true); + }); + it('disables mixed credential behavior when override is set', () => { const slackApiDescription = slackNodeType.credentials[0]; const { showMixedCredentials } = setupOptions('slackApi'); diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.ts index 8e702644b60..243e3727c11 100644 --- a/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.ts +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useNodeCredentialOptions.ts @@ -146,6 +146,9 @@ export function useNodeCredentialOptions( // Gateway-managed credentials have no real DB record but are properly configured if (credential?.__aiGatewayManaged) return true; if (!credential?.id) return false; + // Until the scoped fetch lands there is nothing to match against, and reporting + // a configured credential as missing raises a credential issue that isn't one. + if (!credentialsStore.hasFetchedUsableCredentials) return true; const options = getCredentialOptions([credentialType.name]); return !!options.find((option: ICredentialsResponse) => option.id === credential.id); } diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts index ef065618c31..87e3a97c8a2 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts @@ -1,4 +1,8 @@ -import type { ICredentialsDecryptedResponse, ICredentialsResponse } from './credentials.types'; +import type { + CredentialFetchScope, + ICredentialsDecryptedResponse, + ICredentialsResponse, +} from './credentials.types'; import type { IRestApiContext } from '@n8n/rest-api-client'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import { sleep } from '@n8n/utils/sleep'; @@ -73,9 +77,9 @@ export async function getAllCredentials( }); } -export async function getAllCredentialsForWorkflow( +export async function getUsableCredentials( context: IRestApiContext, - options: { workflowId: string } | { projectId: string }, + options: CredentialFetchScope, ): Promise { return await makeRestApiRequest(context, 'GET', '/credentials/for-workflow', { ...options, diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts index e753a3f6ae0..1208a50a823 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.store.ts @@ -1,5 +1,6 @@ import type { INodeUi } from '@/Interface'; import type { + CredentialFetchScope, ICredentialMap, ICredentialsDecryptedResponse, ICredentialsResponse, @@ -38,9 +39,38 @@ const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', export type CredentialsStore = ReturnType; +export type { CredentialFetchScope }; + +const scopeKey = (scope: CredentialFetchScope): string => + 'workflowId' in scope ? `workflow:${scope.workflowId}` : `project:${scope.projectId}`; + export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { const state = ref({ credentialTypes: {}, credentials: {} }); + /** + * Credentials the backend says are usable in the workflow/project currently open. + * Kept apart from `state.credentials`, which any of ~20 unscoped + * `fetchAllCredentials` callers can replace while a canvas is open — the last + * fetch to resolve would otherwise own the credential picker. + */ + const usableCredentials = ref({}); + /** + * An unfetched slice reads as empty, never as a fallback to the flat map — + * falling back is the bug. Callers that must not act on "no credentials yet" + * check this. + */ + const hasFetchedUsableCredentials = ref(false); + /** The scope the slice currently holds, so it can be refreshed and invalidated. */ + const usableCredentialsScope = ref(null); + /** Bumped per scoped fetch, so only the most recent one publishes its response. */ + let usableCredentialsRequestId = 0; + + const clearUsableCredentials = () => { + usableCredentials.value = {}; + usableCredentialsScope.value = null; + hasFetchedUsableCredentials.value = false; + }; + type CredentialTestStatus = 'pending' | 'success' | 'error'; const credentialTestResults = ref(new Map()); @@ -89,19 +119,17 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { }); const allUsableCredentialsByType = computed(() => { - const credentials = allCredentials.value; - const types = allCredentialTypes.value; + const byType: { [type: string]: ICredentialsResponse[] } = {}; - return types.reduce( - (accu: { [type: string]: ICredentialsResponse[] }, type: ICredentialType) => { - accu[type.name] = credentials.filter((cred: ICredentialsResponse) => { - return cred.type === type.name; - }); + for (const credential of Object.values(usableCredentials.value)) { + (byType[credential.type] ??= []).push(credential); + } - return accu; - }, - {}, - ); + for (const credentials of Object.values(byType)) { + credentials.sort((a, b) => a.name.localeCompare(b.name)); + } + + return byType; }); const allUsableCredentialsForNode = computed(() => { @@ -110,7 +138,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { const nodeType = useNodeTypesStore().getNodeType(node.type, node.typeVersion); if (nodeType?.credentials) { nodeType.credentials.forEach((cred) => { - credentials = credentials.concat(allUsableCredentialsByType.value[cred.name]); + credentials = credentials.concat(allUsableCredentialsByType.value[cred.name] ?? []); }); } return credentials.sort((a, b) => { @@ -326,17 +354,57 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { return credentials; }; - const fetchAllCredentialsForWorkflow = async ( - options: { workflowId: string } | { projectId: string }, + const fetchUsableCredentials = async ( + options: CredentialFetchScope, ): Promise => { - const credentials = await credentialsApi.getAllCredentialsForWorkflow( + const requestedScope = scopeKey(options); + // Opening another workflow or project invalidates the slice up front: while the + // new scope is in flight consumers must read "not fetched yet", never the + // previous scope's credentials. + if (usableCredentialsScope.value && scopeKey(usableCredentialsScope.value) !== requestedScope) { + clearUsableCredentials(); + } + const requestId = ++usableCredentialsRequestId; + + const credentials = await credentialsApi.getUsableCredentials( rootStore.restApiContext, options, ); + // The flat map keeps replace semantics for its existing consumers; the slice is + // what the credential picker reads. setCredentials(credentials); + + // Only the newest request publishes. Several scoped fetches can be in flight at + // once — a mount racing the refresh a quick connect triggers, say — and an older + // response landing last would reinstate a list we already know is out of date, + // whether or not it was fetched for the same scope. + if (requestId !== usableCredentialsRequestId) { + return credentials; + } + + usableCredentials.value = credentials.reduce((accu: ICredentialMap, cred) => { + if (cred.id) { + accu[cred.id] = cred; + } + return accu; + }, {}); + usableCredentialsScope.value = options; + hasFetchedUsableCredentials.value = true; return credentials; }; + /** + * Re-reads the slice for the scope it was last fetched for. Flows that create a + * credential outside a scoped fetch (OAuth quick connect) call this instead of + * inserting locally: only the server can say whether the new credential is usable + * in the scope currently open. + */ + const refreshUsableCredentials = async (): Promise => { + const scope = usableCredentialsScope.value; + if (!scope) return; + await fetchUsableCredentials(scope); + }; + const getCredentialData = async ({ id, }: { @@ -548,6 +616,9 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { return { state, + usableCredentials, + hasFetchedUsableCredentials, + refreshUsableCredentials, credentialTestResults, isCredentialTestedOk, isCredentialTestPending, @@ -576,7 +647,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { upsertCredential, fetchCredentialTypes, fetchAllCredentials, - fetchAllCredentialsForWorkflow, + fetchUsableCredentials, createNewCredential, updateCredential, getCredentialData, diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts index 9cd47a2a1f5..173269d93e1 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.types.ts @@ -74,3 +74,6 @@ export interface ICredentialsState { export interface IShareCredentialsPayload { shareWithIds: string[]; } + +/** What the picker's credential list is narrowed to: an open workflow, else a project. */ +export type CredentialFetchScope = { workflowId: string } | { projectId: string }; diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.utils.test.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.utils.test.ts index e6f15af3259..2912bd5d015 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.utils.test.ts @@ -73,7 +73,7 @@ describe('getAutoSelectedCredential', () => { }); it('picks the most recently updated usable credential of the node type', () => { - credentialsStore.state.credentials = { + credentialsStore.usableCredentials = { older: createCredential({ id: 'older', name: 'Older', @@ -93,7 +93,7 @@ describe('getAutoSelectedCredential', () => { }); it('returns undefined when the node already has a credential set', () => { - credentialsStore.state.credentials = { older: createCredential({ id: 'older' }) }; + credentialsStore.usableCredentials = { older: createCredential({ id: 'older' }) }; const node = createNode({ credentials: { openAiApi: { id: 'older', name: 'Older' } }, @@ -103,7 +103,16 @@ describe('getAutoSelectedCredential', () => { }); it('returns undefined when no usable credentials of the required type exist', () => { - credentialsStore.state.credentials = {}; + credentialsStore.usableCredentials = {}; + + expect(getAutoSelectedCredential(createNode())).toBeUndefined(); + }); + + it('ignores credentials the scoped fetch left out of the usable slice', () => { + // Paste/duplicate into a project must not auto-assign a credential that + // project cannot use, which the backend would then reject on save. + credentialsStore.state.credentials = { personal: createCredential({ id: 'personal' }) }; + credentialsStore.usableCredentials = {}; expect(getAutoSelectedCredential(createNode())).toBeUndefined(); }); @@ -112,7 +121,7 @@ describe('getAutoSelectedCredential', () => { nodeTypesStore.setNodeTypes([ { ...openAiNodeType, name: 'n8n-nodes-base.noOp', credentials: undefined }, ]); - credentialsStore.state.credentials = { older: createCredential() }; + credentialsStore.usableCredentials = { older: createCredential() }; const node = createNode({ type: 'n8n-nodes-base.noOp' }); @@ -124,7 +133,7 @@ describe('getAutoSelectedCredential', () => { openAiApi: openAiApiCredentialType, slackApi: { ...openAiApiCredentialType, name: 'slackApi', displayName: 'Slack' }, }; - credentialsStore.state.credentials = { + credentialsStore.usableCredentials = { openai: createCredential({ id: 'openai', name: 'OpenAI', diff --git a/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.nodeGrouping.test.ts b/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.nodeGrouping.test.ts index 80cf8be70e4..29365d2fa62 100644 --- a/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.nodeGrouping.test.ts +++ b/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.nodeGrouping.test.ts @@ -122,7 +122,7 @@ describe('useWorkflowSetupState – node grouping', () => { credentialsStore.isCredentialTestPending = vi.fn().mockReturnValue(false); credentialsStore.getCredentialData = vi.fn().mockResolvedValue(undefined); credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([]); - credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]); + credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]); nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false); mockGetNodeTypeDisplayableCredentials.mockReturnValue([]); diff --git a/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.test.ts b/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.test.ts index 8f6cc619fd4..52b8f64a44b 100644 --- a/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.test.ts +++ b/packages/frontend/editor-ui/src/features/setupPanel/composables/useWorkflowSetupState.test.ts @@ -146,7 +146,7 @@ describe('useWorkflowSetupState', () => { credentialsStore.isCredentialTestPending = vi.fn().mockReturnValue(false); credentialsStore.getCredentialData = vi.fn().mockResolvedValue(undefined); credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([]); - credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue([]); + credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue([]); nodeTypesStore.isTriggerNode = vi.fn().mockReturnValue(false); workflowExecutionStateStore = useWorkflowExecutionStateStore( createWorkflowDocumentId(workflowsStore.workflowId), diff --git a/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue b/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue index 29596c27817..bc9e752bde5 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue +++ b/packages/frontend/editor-ui/src/features/shared/toolConfig/NodeToolSettingsContent.vue @@ -401,7 +401,7 @@ onMounted(async () => { if (projectId) { await Promise.all([ credentialsStore.fetchCredentialTypes(false), - credentialsStore.fetchAllCredentialsForWorkflow({ projectId }), + credentialsStore.fetchUsableCredentials({ projectId }), ]); } }); diff --git a/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts b/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts index 42c96531227..eb381586066 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/toolConfig/__tests__/NodeToolSettingsContent.test.ts @@ -159,7 +159,7 @@ describe('NodeToolSettingsContent', () => { credentialsStore.allCredentials = []; credentialsStore.setCredentials = vi.fn(); credentialsStore.fetchCredentialTypes = vi.fn().mockResolvedValue(undefined); - credentialsStore.fetchAllCredentialsForWorkflow = vi.fn().mockResolvedValue(undefined); + credentialsStore.fetchUsableCredentials = vi.fn().mockResolvedValue(undefined); projectsStore.personalProject = { id: 'personal-project', name: 'Personal' } as never; projectsStore.setCurrentProject = vi.fn(); projectsStore.fetchAndSetProject = vi.fn().mockResolvedValue(undefined); @@ -344,7 +344,7 @@ describe('NodeToolSettingsContent', () => { await waitFor(() => { expect(credentialsStore.fetchCredentialTypes).toHaveBeenCalledWith(false); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'personal-project', }); }); @@ -358,7 +358,7 @@ describe('NodeToolSettingsContent', () => { }); await waitFor(() => { - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'personal-project', }); }); @@ -376,7 +376,7 @@ describe('NodeToolSettingsContent', () => { await waitFor(() => { expect(projectsStore.getPersonalProject).toHaveBeenCalled(); - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'personal-project', }); }); @@ -400,7 +400,7 @@ describe('NodeToolSettingsContent', () => { }); await waitFor(() => { - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'personal-project', }); }); @@ -427,7 +427,7 @@ describe('NodeToolSettingsContent', () => { }); await waitFor(() => { - expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({ + expect(credentialsStore.fetchUsableCredentials).toHaveBeenCalledWith({ projectId: 'team-project', }); }); diff --git a/packages/testing/playwright/pages/components/WorkflowMenu.ts b/packages/testing/playwright/pages/components/WorkflowMenu.ts index 4e93a4b3d1f..047b035a155 100644 --- a/packages/testing/playwright/pages/components/WorkflowMenu.ts +++ b/packages/testing/playwright/pages/components/WorkflowMenu.ts @@ -59,6 +59,10 @@ export class WorkflowMenu { return this.page.getByTestId('workflow-menu-item-push'); } + getChangeOwnerItem(): Locator { + return this.page.getByTestId('workflow-menu-item-change-owner'); + } + getUnpublishItem(): Locator { return this.page.getByTestId('workflow-menu-item-unpublish'); } @@ -75,6 +79,11 @@ export class WorkflowMenu { await this.getDuplicateItem().click(); } + async openChangeOwner(): Promise { + await this.open(); + await this.getChangeOwnerItem().click(); + } + async clickDelete(): Promise { await this.getDeleteItem().click(); } diff --git a/packages/testing/playwright/tests/e2e/sharing/credential-picker-scope.spec.ts b/packages/testing/playwright/tests/e2e/sharing/credential-picker-scope.spec.ts new file mode 100644 index 00000000000..c0fd63b2c73 --- /dev/null +++ b/packages/testing/playwright/tests/e2e/sharing/credential-picker-scope.spec.ts @@ -0,0 +1,101 @@ +import { nanoid } from 'nanoid'; + +import { test, expect } from '../../../fixtures/base'; + +const TEST_API_KEY = 'test-api-key'; + +test.describe( + 'Node Credential Picker Scoping', + { + annotation: [{ type: 'owner', description: 'Identity & Access' }], + }, + () => { + test('offers only credentials the project can use, across an unscoped credential fetch', async ({ + n8n, + api, + }) => { + // The picker asks the backend which credentials this workflow may use, but + // several everyday actions fetch every credential the *user* can read. Those + // answers used to share one store slot, so the last fetch to land owned the + // dropdown — offering credentials the workflow cannot use, which the backend + // then rejects or silently discards on save. + const personalCredName = `Personal Notion ${nanoid()}`; + await api.credentials.createCredential({ + name: personalCredName, + type: 'notionApi', + data: { apiKey: TEST_API_KEY }, + }); + + const project = await api.projects.createProject(`Team ${nanoid()}`); + const teamCredName = `Team Notion ${nanoid()}`; + const teamCred = await api.credentials.createCredential({ + name: teamCredName, + type: 'notionApi', + data: { apiKey: TEST_API_KEY }, + projectId: project.id, + }); + + const workflow = await api.workflows.createWorkflow( + { + name: `Credential scope ${nanoid()}`, + nodes: [ + { + id: nanoid(), + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [250, 300], + parameters: {}, + }, + { + id: nanoid(), + name: 'Notion', + type: 'n8n-nodes-base.notion', + typeVersion: 2.2, + position: [450, 300], + parameters: { resource: 'database', operation: 'get' }, + credentials: { + notionApi: { id: teamCred.id, name: teamCredName }, + }, + }, + ], + connections: { + 'Manual Trigger': { + main: [[{ node: 'Notion', type: 'main', index: 0 }]], + }, + }, + active: false, + settings: {}, + }, + project.id, + ); + + await n8n.navigate.toWorkflow(workflow.id); + await n8n.canvas.openNode('Notion'); + await expect(n8n.ndv.container).toBeVisible(); + + await n8n.ndv.getNodeCredentialsSelect().click(); + await expect(n8n.ndv.credentials.getOptionByText(teamCredName)).toBeVisible(); + await expect(n8n.ndv.credentials.getOptionByText(personalCredName)).toBeHidden(); + await n8n.page.keyboard.press('Escape'); + await n8n.ndv.close(); + + // "Change owner" loads every credential the user can read, personal ones + // included, without leaving the workflow. The picker must not widen because + // of it. + const unscopedFetch = n8n.page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/rest/credentials' && + response.request().method() === 'GET', + ); + await n8n.workflowMenu.openChangeOwner(); + await unscopedFetch; + await n8n.page.keyboard.press('Escape'); + + await n8n.canvas.openNode('Notion'); + await n8n.ndv.getNodeCredentialsSelect().click(); + await expect(n8n.ndv.credentials.getOptionByText(teamCredName)).toBeVisible(); + await expect(n8n.ndv.credentials.getOptionByText(personalCredName)).toBeHidden(); + }); + }, +);