fix(editor): Always show auth mode dropdown in credential modal (#26280)

This commit is contained in:
Elias Meire
2026-03-03 08:48:19 +00:00
committed by GitHub
parent 863aa1991f
commit 8ac25b8270
7 changed files with 201 additions and 158 deletions
@@ -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",
@@ -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();
});
});
});
@@ -283,7 +283,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
<FreeAiCreditsCallout :credential-type-name="credentialType?.name" />
<CredentialModeSelector
v-if="canWrite && isNewCredential"
v-if="canWrite"
:credential-type="credentialType"
:use-custom-oauth="useCustomOauth"
:show-managed-oauth-options="managedOauthAvailable"
@@ -155,76 +155,7 @@ function makeNode(nodeTypeName: string, authValue: string): INodeUi {
}
describe('CredentialModeSelector', () => {
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();
});
});
@@ -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<Array<DropdownMenuItemProps<CredentialModeOption>>>(() => {
@@ -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);
}
}
</script>
<template>
@@ -211,20 +179,7 @@ function switchToOther(): void {
{{ headingText }}
</N8nText>
<N8nLink
v-if="otherOption"
theme="secondary"
underline
size="small"
:class="$style.switchLink"
data-test-id="credential-mode-switch-link"
@click="switchToOther"
>
{{ otherOption?.name }}
</N8nLink>
<N8nDropdownMenu
v-else
:items="menuItems"
placement="bottom-end"
:extra-popper-class="$style.dropdownContent"
@@ -249,18 +204,6 @@ function switchToOther(): void {
align-items: center;
}
.switchLink {
--link--color--secondary: var(--color--text);
&:hover,
&:focus,
&:active {
:global(span) {
color: var(--color--text--shade-1);
}
}
}
.dropdownContent {
width: auto;
z-index: var(--modals--z);
@@ -143,10 +143,6 @@ export class CredentialModal extends BaseModal {
return this.root.getByTestId('credential-mode-selector');
}
getModeSwitchLink() {
return this.root.getByTestId('credential-mode-switch-link');
}
getModeDropdownTrigger() {
return this.root.getByTestId('credential-mode-dropdown-trigger');
}
@@ -69,11 +69,10 @@ test.describe(
await n8n.ndv.clickCreateNewCredential();
// Gmail has 2 auth options (OAuth2 + Service Account), shown as a switch link
// Gmail has 2 auth options (OAuth2 + Service Account), shown as a dropdown
await expect(n8n.canvas.credentialModal.getModeSelector()).toBeVisible();
await expect(n8n.canvas.credentialModal.getModeSwitchLink()).toBeVisible();
await n8n.canvas.credentialModal.getModeSwitchLink().click();
await n8n.canvas.credentialModal.selectAuthTypeFromDropdown(/Service Account/);
// Fill in the Service Account fields and save
await n8n.canvas.credentialModal.addCredential(