diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index f74e04765e4..78df6f9bde9 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1126,6 +1126,9 @@ "credentials.item.owner": "Owner", "credentials.item.readonly": "Read only", "credentials.item.needsSetup": "Needs first setup", + "credentials.item.connect": "Connect", + "credentials.item.connect.tooltip": "Connect your own account to use this credential in workflows. Only you can use your private credential.", + "credentials.item.connected": "Connected", "credentials.dynamic.tooltip": "This credential uses a resolver to pick the right account at runtime based on who runs the workflow.", "credentials.dynamic.tooltipTitle": "Dynamic credentials", "credentials.dynamic.badge": "Dynamic", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 6e38bf58dc5..a495aef288e 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -315,6 +315,7 @@ export type CredentialsResource = BaseResource & { needsSetup: boolean; isGlobal?: boolean; isResolvable?: boolean; + connectedByMe?: boolean; }; // Base resource types that are always available diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.test.ts index 9d26ca90000..2bb0ca6733f 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.test.ts @@ -3,12 +3,25 @@ import { within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { createTestingPinia } from '@pinia/testing'; import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore } from '@/__tests__/utils'; import CredentialCard from './CredentialCard.vue'; import type { CredentialsResource } from '@/Interface'; import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import { useSettingsStore } from '@/app/stores/settings.store'; +import { useCredentialsStore } from '../credentials.store'; import type { FrontendSettings } from '@n8n/api-types'; +import type { ICredentialsResponse } from '../credentials.types'; + +const mockAuthorize = vi.fn(); +const mockIsOAuthCredentialType = vi.fn(); + +vi.mock('../composables/useCredentialOAuth', () => ({ + useCredentialOAuth: () => ({ + authorize: mockAuthorize, + isOAuthCredentialType: mockIsOAuthCredentialType, + }), +})); const renderComponent = createComponentRenderer(CredentialCard); @@ -40,6 +53,9 @@ describe('CredentialCard', () => { activeModules: ['dynamic-credentials'], } as unknown as FrontendSettings; vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true); + mockAuthorize.mockReset(); + mockIsOAuthCredentialType.mockReset(); + mockIsOAuthCredentialType.mockReturnValue(true); }); it('should render name and home project name', () => { @@ -208,4 +224,136 @@ describe('CredentialCard', () => { expect(queryByTestId('credential-card-dynamic')).not.toBeInTheDocument(); }); }); + + describe('private credentials connect flow', () => { + const privateUnconnectedData = (overrides = {}) => + createCredential({ + id: 'cred-1', + isResolvable: true, + connectedByMe: false, + homeProject: { name: 'Test Project' }, + ...overrides, + }); + + it('should show the Connect button when private and not connected', () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { data: privateUnconnectedData() }, + }); + + expect(getByTestId('credential-card-connect')).toBeInTheDocument(); + expect(queryByTestId('credential-card-not-connected')).not.toBeInTheDocument(); + }); + + it('should still show project badge alongside the Connect button', () => { + const { getByTestId } = renderComponent({ + props: { data: privateUnconnectedData() }, + }); + + expect(getByTestId('card-badge')).toBeInTheDocument(); + expect(getByTestId('credential-card-connect')).toBeInTheDocument(); + }); + + it('should show Connected label when private and connected', () => { + const data = createCredential({ + isResolvable: true, + connectedByMe: true, + homeProject: { name: 'Test Project' }, + }); + + const { getByTestId, queryByTestId } = renderComponent({ props: { data } }); + + expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument(); + const connectedLabel = getByTestId('credential-card-connected'); + expect(connectedLabel).toBeInTheDocument(); + expect(connectedLabel).toHaveTextContent('Connected'); + expect(getByTestId('card-badge')).toBeInTheDocument(); + }); + + it('should not show Connected label for non-resolvable credentials', () => { + const data = createCredential({ + isResolvable: false, + connectedByMe: true, + homeProject: { name: 'Test Project' }, + }); + + const { queryByTestId, getByTestId } = renderComponent({ props: { data } }); + + expect(queryByTestId('credential-card-connected')).not.toBeInTheDocument(); + expect(getByTestId('card-badge')).toBeInTheDocument(); + }); + + it('should show sharing badge when credential is not resolvable', () => { + const data = createCredential({ + isResolvable: false, + connectedByMe: false, + homeProject: { name: 'Test Project' }, + }); + + const { queryByTestId, getByTestId } = renderComponent({ props: { data } }); + + expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument(); + expect(getByTestId('card-badge')).toBeInTheDocument(); + }); + + it('should call authorize with the credential and emit "connected" on success', async () => { + const data = privateUnconnectedData(); + const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse; + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential); + mockAuthorize.mockResolvedValue(true); + + const { getByTestId, emitted } = renderComponent({ props: { data } }); + + await userEvent.click(getByTestId('credential-card-connect')); + + expect(mockAuthorize).toHaveBeenCalledWith(credential); + expect(emitted('connected')).toEqual([[data.id]]); + }); + + it('should fall back to opening the edit modal when the credential is not an OAuth type', async () => { + const data = privateUnconnectedData(); + const credential = { id: data.id, type: 'notOAuthApi' } as ICredentialsResponse; + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential); + mockIsOAuthCredentialType.mockReturnValue(false); + + const { getByTestId, emitted } = renderComponent({ props: { data } }); + + await userEvent.click(getByTestId('credential-card-connect')); + + expect(mockIsOAuthCredentialType).toHaveBeenCalledWith('notOAuthApi'); + expect(mockAuthorize).not.toHaveBeenCalled(); + expect(emitted('click')).toEqual([[data.id]]); + expect(emitted('connected')).toBeUndefined(); + }); + + it('should not emit "connected" if authorize fails', async () => { + const data = privateUnconnectedData(); + const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse; + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential); + mockAuthorize.mockResolvedValue(false); + + const { getByTestId, emitted } = renderComponent({ props: { data } }); + + await userEvent.click(getByTestId('credential-card-connect')); + + expect(mockAuthorize).toHaveBeenCalled(); + expect(emitted('connected')).toBeUndefined(); + }); + + it('should not open the edit modal when Connect button is clicked', async () => { + const data = privateUnconnectedData(); + const credential = { id: data.id, type: 'oAuth2Api' } as ICredentialsResponse; + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential); + mockAuthorize.mockResolvedValue(true); + + const { getByTestId, emitted } = renderComponent({ props: { data } }); + + await userEvent.click(getByTestId('credential-card-connect')); + + expect(emitted('click')).toBeUndefined(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.vue index f14000abb5d..aeeb2cccdb0 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialCard.vue @@ -1,5 +1,5 @@