diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index f52eb9d38f..0e8a2cab1d 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -37,8 +37,8 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import type { Project, ProjectSharingData } from '@/types/projects.types'; -import { assert } from '@n8n/utils/assert'; import type { IMenuItem } from '@n8n/design-system'; +import { assert } from '@n8n/utils/assert'; import { createEventBus } from '@n8n/utils/event-bus'; import { useExternalHooks } from '@/composables/useExternalHooks'; @@ -476,6 +476,19 @@ function getCredentialProperties(name: string): INodeProperties[] { return combineProperties; } +/** + * + * We might get credential with empty parameters from source-control + * which breaks our types and Fe checks + */ +function removePropertiesWithEmptyStrings(data: T): T { + const copy = structuredClone(data); + Object.entries(copy).forEach(([key, value]) => { + if (value === '') delete copy[key]; + }); + return copy; +} + async function loadCurrentCredential() { credentialId.value = props.activeId ?? ''; @@ -494,7 +507,10 @@ async function loadCurrentCredential() { currentCredential.value = currentCredentials; - credentialData.value = (currentCredentials.data as ICredentialDataDecryptedObject) || {}; + credentialData.value = removePropertiesWithEmptyStrings( + (currentCredentials.data as ICredentialDataDecryptedObject) || {}, + ); + if (currentCredentials.sharedWithProjects) { credentialData.value = { ...credentialData.value, diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/__tests__/CredentialEdit.test.ts b/packages/frontend/editor-ui/src/components/CredentialEdit/__tests__/CredentialEdit.test.ts index 0048baf52d..b46d34ec0f 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/__tests__/CredentialEdit.test.ts +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/__tests__/CredentialEdit.test.ts @@ -2,9 +2,182 @@ import { createComponentRenderer } from '@/__tests__/render'; import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue'; import { createTestingPinia } from '@pinia/testing'; import { CREDENTIAL_EDIT_MODAL_KEY, STORES } from '@/constants'; -import { cleanupAppModals, createAppModals, retry } from '@/__tests__/utils'; +import { cleanupAppModals, createAppModals, retry, mockedStore } from '@/__tests__/utils'; import { useCredentialsStore } from '@/stores/credentials.store'; import type { ICredentialsResponse } from '@/Interface'; +import { within } from '@testing-library/vue'; +import type { ICredentialType } from 'n8n-workflow'; + +const oAuth2Api: ICredentialType = { + name: 'oAuth2Api', + displayName: 'OAuth2 API', + documentationUrl: 'httpRequest', + genericAuth: true, + properties: [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'options', + options: [ + { + name: 'Authorization Code', + value: 'authorizationCode', + }, + { + name: 'Client Credentials', + value: 'clientCredentials', + }, + { + name: 'PKCE', + value: 'pkce', + }, + ], + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'string', + displayOptions: { + show: { + grantType: ['authorizationCode', 'pkce'], + }, + }, + default: '', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Client ID', + name: 'clientId', + type: 'string', + default: '', + required: true, + }, + { + displayName: 'Client Secret', + name: 'clientSecret', + type: 'string', + typeOptions: { + password: true, + }, + default: '', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'string', + default: '', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'string', + displayOptions: { + show: { + grantType: ['authorizationCode', 'pkce'], + }, + }, + default: '', + description: + 'For some services additional query parameters have to be set which can be defined here', + placeholder: 'access_type=offline', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Body', + value: 'body', + description: 'Send credentials in body', + }, + { + name: 'Header', + value: 'header', + description: 'Send credentials as Basic Auth header', + }, + ], + default: 'header', + }, + { + displayName: 'Ignore SSL Issues (Insecure)', + name: 'ignoreSSLIssues', + type: 'boolean', + default: false, + doNotInherit: true, + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/nodes/GraphQL/graphql.png', + supportedNodes: ['n8n-nodes-base.graphql', 'n8n-nodes-base.httpRequest'], +}; + +const googleOAuth2Api: ICredentialType = { + name: 'googleOAuth2Api', + extends: ['oAuth2Api'], + displayName: 'Google OAuth2 API', + documentationUrl: 'google/oauth-generic', + properties: [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://accounts.google.com/o/oauth2/v2/auth', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://oauth2.googleapis.com/token', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: 'access_type=offline&prompt=consent', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'body', + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/credentials/icons/Google.svg', + supportedNodes: [], +}; + +const googleBigQueryOAuth2Api: ICredentialType = { + name: 'googleBigQueryOAuth2Api', + extends: ['googleOAuth2Api'], + displayName: 'Google BigQuery OAuth2 API', + documentationUrl: 'google/oauth-single-service', + properties: [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: + 'https://www.googleapis.com/auth/bigquery https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/drive', + }, + ], + iconUrl: 'icons/n8n-nodes-base/dist/nodes/Google/BigQuery/googleBigQuery.svg', + supportedNodes: ['n8n-nodes-base.googleBigQuery'], +}; vi.mock('@/permissions', () => ({ getResourcePermissions: vi.fn(() => ({ @@ -123,4 +296,62 @@ describe('CredentialEdit', () => { await retry(() => expect(queryByText('Connection')).not.toBeInTheDocument()); await retry(() => expect(queryByText('Sharing')).not.toBeInTheDocument()); }); + + test.each([ + { + case: 'valid credential', + data: { + clientId: 'client_id', + clientSecret: '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da', + }, + }, + { + case: 'malformed credential from source-control', + data: { + grantType: '', + authUrl: '', + accessTokenUrl: '', + clientId: 'client_id', + clientSecret: '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da', + scope: '', + authQueryParameters: '', + authentication: '', + }, + }, + ])('should show Sign in with Google for $case data', async ({ data }) => { + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialData.mockResolvedValueOnce({ + // @ts-expect-error data is decrypted + data, + createdAt: '2025-05-13T10:45:45.588Z', + updatedAt: '2025-05-15T12:36:01.826Z', + id: '6ufuMkOc7bb3LyGV', + name: 'Google BigQuery account', + type: 'googleBigQueryOAuth2Api', + isManaged: false, + sharedWithProjects: [], + scopes: ['credential:update'], + oauthTokenData: false, + }); + + credentialsStore.state.credentialTypes = { + [oAuth2Api.name]: oAuth2Api, + [googleOAuth2Api.name]: googleOAuth2Api, + [googleBigQueryOAuth2Api.name]: googleBigQueryOAuth2Api, + }; + + const { getByTestId } = renderComponent({ + props: { + activeId: '6ufuMkOc7bb3LyGV', + modalName: CREDENTIAL_EDIT_MODAL_KEY, + mode: 'edit', + }, + }); + await retry(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled()); + await retry(() => expect(getByTestId('credential-edit-dialog')).toBeInTheDocument()); + + expect( + within(getByTestId('credential-edit-dialog')).getByTestId('oauth-connect-button'), + ).toBeInTheDocument(); + }); });