Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/frontend/@n8n/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -937,9 +937,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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
});

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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', ''),
Expand All @@ -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', ''),
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});

Expand Down
Loading
Loading