From 814e2a89241bdc6a26defb6bfd3d87abdc477ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 10 Feb 2025 09:43:50 +0100 Subject: [PATCH] fix(editor): Load only personal credentials when setting up a template (#12826) --- .../CredentialPicker.test.constants.ts | 276 ++++++++++++++++++ .../CredentialPicker/CredentialPicker.test.ts | 78 +++++ .../CredentialPicker/CredentialPicker.vue | 6 +- 3 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.constants.ts create mode 100644 packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.ts diff --git a/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.constants.ts b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.constants.ts new file mode 100644 index 0000000000..b23df04547 --- /dev/null +++ b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.constants.ts @@ -0,0 +1,276 @@ +import type { ICredentialMap, ICredentialTypeMap } from '@/Interface'; + +export const TEST_CREDENTIALS: ICredentialMap = { + // OpenAI credential in personal + 1: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: '1', + name: 'OpenAi account', + data: 'test123', + type: 'openAiApi', + isManaged: false, + homeProject: { + id: '1', + type: 'personal', + name: 'Kobi Dog ', + icon: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + sharedWithProjects: [ + { + id: '2', + type: 'team', + name: 'Test Project', + icon: { type: 'icon', value: 'exchange-alt' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + scopes: [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ], + }, + // Supabase credential in another project + 2: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: '2', + name: 'Supabase account', + data: 'test123', + type: 'supabaseApi', + isManaged: false, + homeProject: { + id: '2', + type: 'team', + name: 'Test Project', + icon: { type: 'icon', value: 'exchange-alt' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + sharedWithProjects: [], + scopes: [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ], + }, + // Slack account in personal + 3: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: '3', + name: 'Slack account', + data: 'test123', + type: 'slackOAuth2Api', + isManaged: false, + homeProject: { + id: '1', + type: 'personal', + name: 'Kobi Dog ', + icon: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + sharedWithProjects: [], + scopes: [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ], + }, + // OpenAI credential in another project + 4: { + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: '4', + name: '[PROJECT] OpenAI Account', + data: 'test123', + type: 'openAiApi', + isManaged: false, + homeProject: { + id: '2', + type: 'team', + name: 'Test Project', + icon: { type: 'icon', value: 'exchange-alt' }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + sharedWithProjects: [], + scopes: [ + 'credential:create', + 'credential:delete', + 'credential:list', + 'credential:move', + 'credential:read', + 'credential:share', + 'credential:update', + ], + }, +}; + +export const TEST_CREDENTIAL_TYPES: ICredentialTypeMap = { + openAiApi: { + name: 'openAiApi', + displayName: 'OpenAi', + documentationUrl: 'openAi', + properties: [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { + password: true, + }, + required: true, + default: '', + }, + { + displayName: 'Base URL', + name: 'url', + type: 'string', + default: 'https://api.openai.com/v1', + description: 'Override the base URL for the API', + }, + ], + authenticate: { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }, + test: { + request: { + baseURL: '={{$credentials?.url}}', + url: '/models', + }, + }, + iconUrl: { + light: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.svg', + dark: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.dark.svg', + }, + supportedNodes: [ + 'n8n-nodes-base.openAi', + '@n8n/n8n-nodes-langchain.embeddingsOpenAi', + '@n8n/n8n-nodes-langchain.lmChatOpenAi', + '@n8n/n8n-nodes-langchain.lmOpenAi', + ], + }, + supabaseApi: { + name: 'supabaseApi', + displayName: 'Supabase API', + documentationUrl: 'supabase', + properties: [ + { + displayName: 'Host', + name: 'host', + type: 'string', + placeholder: 'https://your_account.supabase.co', + default: '', + }, + { + displayName: 'Service Role Secret', + name: 'serviceRole', + type: 'string', + default: '', + typeOptions: { + password: true, + }, + }, + ], + authenticate: { + type: 'generic', + properties: { + headers: { + apikey: '={{$credentials.serviceRole}}', + Authorization: '=Bearer {{$credentials.serviceRole}}', + }, + }, + }, + test: { + request: { + baseURL: '={{$credentials.host}}/rest/v1', + headers: { + Prefer: 'return=representation', + }, + url: '/', + }, + }, + iconUrl: 'icons/n8n-nodes-base/dist/nodes/Supabase/supabase.svg', + supportedNodes: ['n8n-nodes-base.supabase'], + }, + slackOAuth2Api: { + name: 'slackOAuth2Api', + extends: ['oAuth2Api'], + displayName: 'Slack OAuth2 API', + documentationUrl: 'slack', + properties: [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://slack.com/oauth/v2/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://slack.com/api/oauth.v2.access', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: 'chat:write', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: + 'user_scope=channels:read channels:write chat:write files:read files:write groups:read im:read mpim:read reactions:read reactions:write stars:read stars:write usergroups:write usergroups:read users.profile:read users.profile:write users:read', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'body', + }, + { + displayName: + 'If you get an Invalid Scopes error, make sure you add the correct one here to your Slack integration', + name: 'notice', + type: 'notice', + default: '', + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/nodes/Slack/slack.svg', + supportedNodes: ['n8n-nodes-base.slack'], + }, +}; + +export const PERSONAL_OPENAI_CREDENTIAL = TEST_CREDENTIALS[1]; +export const PROJECT_OPENAI_CREDENTIAL = TEST_CREDENTIALS[4]; diff --git a/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.ts b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.ts new file mode 100644 index 0000000000..816b78e818 --- /dev/null +++ b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.test.ts @@ -0,0 +1,78 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; +import { useCredentialsStore } from '@/stores/credentials.store'; +import { createTestingPinia } from '@pinia/testing'; +import CredentialPicker from './CredentialPicker.vue'; +import { + PERSONAL_OPENAI_CREDENTIAL, + PROJECT_OPENAI_CREDENTIAL, + TEST_CREDENTIAL_TYPES, + TEST_CREDENTIALS, +} from './CredentialPicker.test.constants'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/vue'; + +vi.mock('vue-router', () => { + const push = vi.fn(); + const resolve = vi.fn().mockReturnValue({ href: 'https://test.com' }); + return { + useRouter: () => ({ + push, + resolve, + }), + useRoute: () => ({}), + RouterLink: vi.fn(), + }; +}); + +let credentialsStore: ReturnType>; + +const renderComponent = createComponentRenderer(CredentialPicker); + +describe('CredentialPicker', () => { + beforeEach(() => { + createTestingPinia(); + credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.state.credentials = TEST_CREDENTIALS; + credentialsStore.state.credentialTypes = TEST_CREDENTIAL_TYPES; + }); + + it('should render', () => { + expect(() => + renderComponent({ + props: { + appName: 'OpenAI', + credentialType: 'openAiApi', + selectedCredentialId: null, + }, + }), + ).not.toThrowError(); + }); + + it('should only render personal credentials of the specified type', async () => { + const TEST_APP_NAME = 'OpenAI'; + const TEST_CREDENTIAL_TYPE = 'openAiApi'; + const { getByTestId } = renderComponent({ + props: { + appName: TEST_APP_NAME, + credentialType: TEST_CREDENTIAL_TYPE, + selectedCredentialId: null, + }, + }); + expect(getByTestId('credential-dropdown')).toBeInTheDocument(); + expect(getByTestId('credential-dropdown')).toHaveAttribute( + 'credential-type', + TEST_CREDENTIAL_TYPE, + ); + // Open the dropdown + await userEvent.click(getByTestId('credential-dropdown')); + // Personal openAI credential should be in the dropdown + expect( + screen.getByTestId(`node-credentials-select-item-${PERSONAL_OPENAI_CREDENTIAL.id}`), + ).toBeInTheDocument(); + // OpenAI credential that belong to other project should not be in the dropdown + expect( + screen.queryByTestId(`node-credentials-select-item-${PROJECT_OPENAI_CREDENTIAL.id}`), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue index 24fce63090..8bddeddd85 100644 --- a/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue +++ b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue @@ -26,7 +26,10 @@ const i18n = useI18n(); const wasModalOpenedFromHere = ref(false); const availableCredentials = computed(() => { - return credentialsStore.getCredentialsByType(props.credentialType); + const credByType = credentialsStore.getCredentialsByType(props.credentialType); + // Only show personal credentials since templates are created in personal by default + // Here, we don't care about sharing because credentials cannot be shared with personal project + return credByType.filter((credential) => credential.homeProject?.type === 'personal'); }); const credentialOptions = computed(() => { @@ -98,6 +101,7 @@ listenForModalChanges({ :credential-type="props.credentialType" :credential-options="credentialOptions" :selected-credential-id="props.selectedCredentialId" + data-test-id="credential-dropdown" @credential-selected="onCredentialSelected" @new-credential="createNewCredential" />