From 8ac25b827021625ceb96d06bf641f2cf89721dde Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Tue, 3 Mar 2026 09:48:19 +0100 Subject: [PATCH] fix(editor): Always show auth mode dropdown in credential modal (#26280) --- .../frontend/@n8n/i18n/src/locales/en.json | 4 +- .../CredentialEdit/CredentialConfig.test.ts | 170 +++++++++++++++++- .../CredentialEdit/CredentialConfig.vue | 2 +- .../CredentialModeSelector.test.ts | 111 +++--------- .../CredentialEdit/CredentialModeSelector.vue | 63 +------ .../pages/components/CredentialModal.ts | 4 - .../tests/e2e/credentials/crud.spec.ts | 5 +- 7 files changed, 201 insertions(+), 158 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c0981aad625..3daa468c619 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -949,9 +949,7 @@ "credentialEdit.credentialConfig.missingCredentialType": "This credential's type isn't available. This usually happens when a previously installed community or custom node was uninstalled.", "credentialEdit.credentialConfig.oauthModeManaged": "Managed OAuth2 (recommended)", "credentialEdit.credentialConfig.oauthModeCustom": "Custom OAuth2", - "credentialEdit.credentialConfig.oauthModeManagedTitle": "Setup managed OAuth", - "credentialEdit.credentialConfig.oauthModeCustomTitle": "Setup custom OAuth", - "credentialEdit.credentialConfig.genericTitle": "Setup {credential}", + "credentialEdit.credentialConfig.setupCredential": "Setup credential", "credentialEdit.credentialConfig.quickConnect": "Use quick connect", "credentialEdit.credentialConfig.quickConnectTitle": "Connect to set up a credential", diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts index 454564c2fe1..2a13a55d4e0 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.test.ts @@ -1,13 +1,22 @@ import CredentialConfig from './CredentialConfig.vue'; import { screen } from '@testing-library/vue'; -import type { ICredentialDataDecryptedObject, ICredentialType } from 'n8n-workflow'; +import type { + ICredentialDataDecryptedObject, + ICredentialType, + INodeTypeDescription, +} from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import { createTestingPinia } from '@pinia/testing'; import type { RenderOptions } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render'; import { STORES } from '@n8n/stores'; import { vi } from 'vitest'; import { useCredentialsStore } from '../../credentials.store'; +import { useNDVStore } from '@/features/ndv/shared/ndv.store'; +import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import { mockedStore } from '@/__tests__/utils'; import { addCredentialTranslation } from '@n8n/i18n'; +import type { INodeUi } from '@/Interface'; vi.mock('@n8n/i18n', async () => { const actual = await vi.importActual('@n8n/i18n'); @@ -440,4 +449,163 @@ describe('CredentialConfig', () => { expect(screen.queryByTestId('copy-input')).not.toBeInTheDocument(); }); }); + + describe('Mode Selector visibility', () => { + const dropboxApiType: ICredentialType = { + name: 'dropboxApi', + displayName: 'Dropbox API', + properties: [ + { displayName: 'Access Token', name: 'accessToken', type: 'string', default: '' }, + ], + }; + + const dropboxOAuth2ApiType: ICredentialType = { + name: 'dropboxOAuth2Api', + extends: ['oAuth2Api'], + displayName: 'Dropbox OAuth2 API', + properties: [], + }; + + const twoAuthNodeType = { + displayName: 'Dropbox', + name: 'n8n-nodes-base.dropbox', + group: ['input'], + version: 1, + description: 'Access data on Dropbox', + defaults: { name: 'Dropbox' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [ + { + name: 'dropboxApi', + required: true, + displayOptions: { show: { authentication: ['accessToken'] } }, + }, + { + name: 'dropboxOAuth2Api', + required: true, + displayOptions: { show: { authentication: ['oAuth2'] } }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { name: 'Access Token', value: 'accessToken' }, + { name: 'OAuth2', value: 'oAuth2' }, + ], + default: 'accessToken', + }, + ], + } as unknown as INodeTypeDescription; + + const writePermissions = { + create: true, + update: true, + read: true, + delete: true, + share: true, + list: true, + move: true, + }; + + function setupMultiAuthStores() { + const pinia = createTestingPinia({ + stubActions: false, + initialState: { + [STORES.SETTINGS]: { + settings: { enterprise: { sharing: false, externalSecrets: false } }, + }, + }, + }); + + const ndvStore = mockedStore(useNDVStore); + ndvStore.activeNode = { + parameters: { authentication: 'accessToken' }, + type: 'n8n-nodes-base.dropbox', + typeVersion: 1, + position: [0, 0], + id: 'test-node-id', + name: 'Test Node', + credentials: {}, + } as INodeUi; + + const nodeTypesStore = mockedStore(useNodeTypesStore); + nodeTypesStore.setNodeTypes([twoAuthNodeType]); + + const credStore = useCredentialsStore(); + credStore.state.credentialTypes = { + dropboxApi: dropboxApiType, + dropboxOAuth2Api: dropboxOAuth2ApiType, + }; + + return pinia; + } + + it('should show mode selector for existing credential with update permission', () => { + const pinia = setupMultiAuthStores(); + + renderComponent({ + pinia, + props: { + isManaged: false, + mode: 'edit', + credentialId: 'existing-cred-123', + credentialType: dropboxApiType, + credentialProperties: dropboxApiType.properties, + credentialData: {} as ICredentialDataDecryptedObject, + credentialPermissions: writePermissions, + }, + }); + + expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); + }); + + it('should show mode selector for new credential with create permission', () => { + const pinia = setupMultiAuthStores(); + + renderComponent({ + pinia, + props: { + isManaged: false, + mode: 'new', + credentialType: dropboxApiType, + credentialProperties: dropboxApiType.properties, + credentialData: {} as ICredentialDataDecryptedObject, + credentialPermissions: writePermissions, + }, + }); + + expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); + }); + + it('should not show mode selector for existing credential without update permission', () => { + const pinia = setupMultiAuthStores(); + + renderComponent({ + pinia, + props: { + isManaged: false, + mode: 'edit', + credentialId: 'existing-cred-123', + credentialType: dropboxApiType, + credentialProperties: dropboxApiType.properties, + credentialData: {} as ICredentialDataDecryptedObject, + credentialPermissions: { + create: false, + update: false, + read: true, + delete: false, + share: false, + list: true, + move: false, + }, + }, + }); + + expect(screen.queryByTestId('credential-mode-selector')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue index 7f908eef9bf..3bd9c19d0c5 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialConfig.vue @@ -283,7 +283,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => { { - describe('2 auth options (switch link)', () => { - it('should show switch link when there are exactly 2 options', () => { - const pinia = setupStores({ - nodeType: twoAuthNodeType, - node: makeNode('n8n-nodes-base.dropbox', 'accessToken'), - credentialTypes: { - dropboxApi: dropboxApiType, - dropboxOAuth2Api: dropboxOAuth2ApiType, - }, - }); - - renderComponent({ - pinia, - props: { - credentialType: dropboxApiType, - }, - }); - - expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); - expect(screen.getByTestId('credential-mode-switch-link')).toBeInTheDocument(); - expect(screen.queryByTestId('credential-mode-dropdown-trigger')).not.toBeInTheDocument(); - }); - - it('should emit authTypeChanged when clicking switch link', async () => { - const pinia = setupStores({ - nodeType: twoAuthNodeType, - node: makeNode('n8n-nodes-base.dropbox', 'accessToken'), - credentialTypes: { - dropboxApi: dropboxApiType, - dropboxOAuth2Api: dropboxOAuth2ApiType, - }, - }); - - const { emitted } = renderComponent({ - pinia, - props: { - credentialType: dropboxApiType, - }, - }); - - await userEvent.click(screen.getByTestId('credential-mode-switch-link')); - - expect(emitted('update:authType')).toHaveLength(1); - expect(emitted('update:authType')[0]).toEqual([{ type: 'oAuth2' }]); - }); - - it('should emit the other auth type when switch link is clicked from OAuth2', async () => { - const pinia = setupStores({ - nodeType: twoAuthNodeType, - node: makeNode('n8n-nodes-base.dropbox', 'oAuth2'), - credentialTypes: { - dropboxApi: dropboxApiType, - dropboxOAuth2Api: dropboxOAuth2ApiType, - }, - }); - - const { emitted } = renderComponent({ - pinia, - props: { - credentialType: dropboxOAuth2ApiType, - }, - }); - - // From OAuth2, switch to Access Token - await userEvent.click(screen.getByTestId('credential-mode-switch-link')); - expect(emitted('update:authType')[0]).toEqual([{ type: 'accessToken' }]); - }); - }); - - describe('3+ auth options (dropdown menu)', () => { + describe('dropdown menu', () => { it('should show dropdown trigger when there are 3+ options', () => { const pinia = setupStores({ nodeType: threeAuthNodeType, @@ -245,7 +176,6 @@ describe('CredentialModeSelector', () => { expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); expect(screen.getByTestId('credential-mode-dropdown-trigger')).toBeInTheDocument(); - expect(screen.queryByTestId('credential-mode-switch-link')).not.toBeInTheDocument(); expect(screen.getByText('Setup credential')).toBeInTheDocument(); }); @@ -331,7 +261,6 @@ describe('CredentialModeSelector', () => { // plus the Access Token option = 3 total, so dropdown should appear expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); expect(screen.getByTestId('credential-mode-dropdown-trigger')).toBeInTheDocument(); - expect(screen.queryByTestId('credential-mode-switch-link')).not.toBeInTheDocument(); expect(screen.getByText('Setup credential')).toBeInTheDocument(); }); @@ -468,7 +397,7 @@ describe('CredentialModeSelector', () => { properties: [], } as unknown as INodeTypeDescription; - it('should show "Use quick connect" link in manual mode and emit quickConnectEnabled on click', async () => { + it('should emit quickConnectEnabled when selecting quick connect from dropdown', async () => { const pinia = setupStores({ nodeType: singleCredNodeType, node: makeNode('n8n-nodes-base.firecrawl', ''), @@ -484,17 +413,23 @@ describe('CredentialModeSelector', () => { }, }); - const link = screen.getByTestId('credential-mode-switch-link'); - expect(link).toHaveTextContent('Use quick connect'); - expect(screen.queryByTestId('credential-mode-dropdown-trigger')).not.toBeInTheDocument(); + expect(screen.getByTestId('credential-mode-dropdown-trigger')).toBeInTheDocument(); - await userEvent.click(link); + await userEvent.click(screen.getByTestId('credential-mode-dropdown-trigger')); - expect(emitted('update:authType')).toHaveLength(1); - expect(emitted('update:authType')[0]).toEqual([{ type: '', quickConnectEnabled: true }]); + await waitFor(() => { + expect(document.querySelector('[role="menu"]')).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('menuitem', { name: /Use quick connect/ })); + + await waitFor(() => { + expect(emitted('update:authType')).toHaveLength(1); + expect(emitted('update:authType')[0]).toEqual([{ type: '', quickConnectEnabled: true }]); + }); }); - it('should show "Set up manually" link in QC mode and emit manual fallback on click', async () => { + it('should emit manual fallback when selecting set up manually from dropdown in QC mode', async () => { const pinia = setupStores({ nodeType: singleCredNodeType, node: makeNode('n8n-nodes-base.firecrawl', ''), @@ -510,13 +445,18 @@ describe('CredentialModeSelector', () => { }, }); - const link = screen.getByTestId('credential-mode-switch-link'); - expect(link).toHaveTextContent('Set up manually'); + await userEvent.click(screen.getByTestId('credential-mode-dropdown-trigger')); - await userEvent.click(link); + await waitFor(() => { + expect(document.querySelector('[role="menu"]')).toBeInTheDocument(); + }); - expect(emitted('update:authType')).toHaveLength(1); - expect(emitted('update:authType')[0]).toEqual([{ type: '' }]); + await userEvent.click(screen.getByRole('menuitem', { name: /Set up manually/ })); + + await waitFor(() => { + expect(emitted('update:authType')).toHaveLength(1); + expect(emitted('update:authType')[0]).toEqual([{ type: '' }]); + }); }); it('should not show selector when quickConnectAvailable is false for single-cred nodes', () => { @@ -559,7 +499,6 @@ describe('CredentialModeSelector', () => { // QC + Access Token + OAuth2 = 3 options → dropdown expect(screen.getByTestId('credential-mode-selector')).toBeInTheDocument(); expect(screen.getByTestId('credential-mode-dropdown-trigger')).toBeInTheDocument(); - expect(screen.queryByTestId('credential-mode-switch-link')).not.toBeInTheDocument(); }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialModeSelector.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialModeSelector.vue index 217d657d70c..df116fcae60 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialModeSelector.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialModeSelector.vue @@ -4,7 +4,7 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow'; import { computed } from 'vue'; -import { N8nButton, N8nIcon, N8nLink, N8nText } from '@n8n/design-system'; +import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system'; import { N8nDropdownMenu, type DropdownMenuItemProps, @@ -147,41 +147,15 @@ function isSelected(option: CredentialModeOption): boolean { } const showSelector = computed(() => options.value.length >= 2); -const showDropdown = computed(() => options.value.length > 2); const selectedOption = computed(() => { - const selected = options.value.find((option) => isSelected(option.value)) ?? null; - return selected; -}); - -const otherOption = computed(() => { - if (showDropdown.value) return null; - return options.value.find((option) => !isSelected(option.value)) ?? null; + return options.value.find((option) => isSelected(option.value)) ?? null; }); const headingText = computed(() => { if (props.isQuickConnectMode) { return i18n.baseText('credentialEdit.credentialConfig.quickConnectTitle'); } - - if (options.value.length > 2) { - return i18n.baseText('credentialEdit.credentialConfig.setupCredential'); - } - - if (props.showManagedOauthOptions) { - if (props.useCustomOauth) { - return i18n.baseText('credentialEdit.credentialConfig.oauthModeCustomTitle'); - } - return i18n.baseText('credentialEdit.credentialConfig.oauthModeManagedTitle'); - } - - const authName = selectedAuthType.value?.name; - if (!authName) { - return i18n.baseText('credentialEdit.credentialConfig.setupCredential'); - } - - return i18n.baseText('credentialEdit.credentialConfig.genericTitle', { - interpolate: { credential: authName }, - }); + return i18n.baseText('credentialEdit.credentialConfig.setupCredential'); }); const menuItems = computed>>(() => { @@ -196,12 +170,6 @@ function onOptionChange(value: CredentialModeOption): void { if (isSelected(value)) return; emit('update:authType', value); } - -function switchToOther(): void { - if (otherOption.value) { - onOptionChange(otherOption.value.value); - } -}