mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(editor): Always show auth mode dropdown in credential modal (#26280)
This commit is contained in:
@@ -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",
|
||||
|
||||
+169
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+25
-86
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-60
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user