diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7787acde3aa..f0e8e1c270f 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1139,6 +1139,11 @@ "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", + "credentials.private.badge": "Private", + "credentials.private.callout.title": "This node is set up to use end user accounts", + "credentials.private.callout.connected": "Your account is connected", + "credentials.private.callout.notConnected": "Your account isn't connected yet.", + "credentials.private.callout.connect": "Connect", "credentials.dynamic.warning.noResolver": "Dynamic credentials are enabled, but no resolver is selected. Select one in {workflowSettings} to resolve user accounts at run time.", "credentials.dynamic.warning.noResolver.workflowSettings": "Workflow settings", "credentials.dynamic.warning.noResolver.documentation": "documentation", @@ -3710,6 +3715,7 @@ "timeAgo.weeksAgo": "%s weeks ago", "timeAgo.yearsAgo": "%s years ago", "nodeIssues.credentials.notSet": "Credentials for {type} are not set.", + "nodeIssues.credentials.privateNotConnected": "'{name}' private credential is not connected for you. Connect yours to execute this step manually.", "nodeIssues.credentials.notAvailable": "Credential is not available", "nodeIssues.credentials.doNotExist": "Credentials with name {name} do not exist for {type}.", "nodeIssues.credentials.doNotExist.hint": "You can create credentials with the exact name and then they get auto-selected on refresh..", diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts index aaa839459a8..72805155d7b 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.test.ts @@ -1,4 +1,4 @@ -import { shallowRef } from 'vue'; +import { shallowRef, computed } from 'vue'; import { setActivePinia } from 'pinia'; import type { ExecutionStatus, @@ -18,8 +18,20 @@ import { mockedStore } from '@/__tests__/utils'; import { mock } from 'vitest-mock-extended'; import { faker } from '@faker-js/faker'; import type { INodeUi } from '@/Interface'; -import type { IUsedCredential } from '@/features/credentials/credentials.types'; +import type { + IUsedCredential, + ICredentialsResponse, +} from '@/features/credentials/credentials.types'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import { useCredentialsStore } from '@/features/credentials/credentials.store'; + +vi.mock('@/features/resolvers/composables/useDynamicCredentials', () => ({ + useDynamicCredentials: vi.fn(), +})); + +import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; + +const mockedUseDynamicCredentials = vi.mocked(useDynamicCredentials); const mockDocumentStoreUsedCredentials: Record = {}; @@ -52,6 +64,12 @@ describe('useNodeHelpers()', () => { mockedStore(useWorkflowsStore).workflowId = 'workflow-id'; }); + beforeEach(() => { + mockedUseDynamicCredentials.mockReturnValue({ + isEnabled: computed(() => true), + } as ReturnType); + }); + afterEach(() => { vi.clearAllMocks(); // Clear mock document store state @@ -784,4 +802,168 @@ describe('useNodeHelpers()', () => { getNodeParametersIssuesSpy.mockRestore(); }); }); + + describe('credential issues for private credentials', () => { + const notionNodeType: INodeTypeDescription = { + displayName: 'Notion', + name: 'n8n-nodes-base.notion', + group: ['transform'], + version: 1, + description: 'Notion node', + defaults: { name: 'Notion' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [{ name: 'notionApi', required: true }], + properties: [], + }; + + const httpRequestNodeType: INodeTypeDescription = { + displayName: 'HTTP Request', + name: 'n8n-nodes-base.httpRequest', + group: ['transform'], + version: 3, + description: 'HTTP Request node', + defaults: { name: 'HTTP Request' }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + credentials: [], + properties: [], + }; + + const makePrivateCred = (overrides: Partial = {}): ICredentialsResponse => + ({ + id: 'cred-123', + name: 'My Notion', + type: 'notionApi', + isResolvable: true, + connectedByMe: false, + ...overrides, + }) as ICredentialsResponse; + + beforeEach(() => { + mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(notionNodeType); + }); + + it('emits privateNotConnected when declared-credential node has private cred not connected', () => { + const cred = makePrivateCred({ connectedByMe: false }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + + expect(result?.credentials?.notionApi).toBeDefined(); + expect(result?.credentials?.notionApi[0]).toContain('My Notion'); + }); + + it('emits no issue when declared-credential node has private cred connected', () => { + const cred = makePrivateCred({ connectedByMe: true }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + + expect(result?.credentials).toBeUndefined(); + }); + + it('emits privateNotConnected for predefined-OAuth credential (HTTP Request, not in node type credentials array)', () => { + mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(httpRequestNodeType); + + const cred = makePrivateCred({ type: 'slackOAuth2Api', connectedByMe: false }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.httpRequest', + parameters: { + authentication: 'predefinedCredentialType', + nodeCredentialType: 'slackOAuth2Api', + }, + credentials: { slackOAuth2Api: { id: 'cred-123', name: 'My Notion' } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(httpRequestNodeType, node, mock(), ['parameters']); + + expect(result?.credentials?.slackOAuth2Api).toBeDefined(); + expect(result?.credentials?.slackOAuth2Api[0]).toContain('My Notion'); + }); + + it('emits no issue for static (non-resolvable) credential regardless of connectedByMe', () => { + const cred = makePrivateCred({ isResolvable: false, connectedByMe: false }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + + expect(result?.credentials).toBeUndefined(); + }); + + it('emits no issue for AI-gateway managed private credential', () => { + const cred = makePrivateCred({ connectedByMe: false }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: { notionApi: { id: 'cred-123', name: 'My Notion', __aiGatewayManaged: true } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + + expect(result?.credentials).toBeUndefined(); + }); + + it('preserves declared-loop notSet issue and does not overwrite with private check', () => { + // Credential not set (undefined id) — declared loop fires notSet, private scan must skip + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: {}, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + + expect(result?.credentials?.notionApi).toBeDefined(); + expect(result?.credentials?.notionApi[0]).toContain('Notion'); + }); + + it('emits no issue when dynamic credentials feature is disabled', () => { + mockedUseDynamicCredentials.mockReturnValue({ + isEnabled: computed(() => false), + } as ReturnType); + + const cred = makePrivateCred({ connectedByMe: false }); + mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred); + mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]); + + const node: INodeUi = createTestNode({ + type: 'n8n-nodes-base.notion', + credentials: { notionApi: { id: 'cred-123', name: 'My Notion' } }, + }); + + const { getNodeIssues } = useNodeHelpers(); + const result = getNodeIssues(notionNodeType, node, mock(), ['parameters']); + expect(result?.credentials?.notionApi).toBeUndefined(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts index 875ff9f5ceb..c1954f62b4f 100644 --- a/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts +++ b/packages/frontend/editor-ui/src/app/composables/useNodeHelpers.ts @@ -49,6 +49,7 @@ import { hasPermission } from '@/app/utils/rbac/permissions'; import { useCanvasStore } from '@/app/stores/canvas.store'; import { useSettingsStore } from '@/app/stores/settings.store'; import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; +import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; declare namespace HttpRequestNode { namespace V2 { @@ -68,8 +69,8 @@ export function useNodeHelpers() { const settingsStore = useSettingsStore(); const i18n = useI18n(); const canvasStore = useCanvasStore(); - const workflowDocumentStore = injectWorkflowDocumentStore(); + const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials(); const isInsertingNodes = ref(false); const credentialsUpdated = ref(false); @@ -413,6 +414,29 @@ export function useNodeHelpers() { return null; } + function collectPrivateCredentialIssues( + node: INodeUi, + foundIssues: INodeIssueObjectProperty, + ): void { + if (!isDynamicCredentialsEnabled.value) return; + + for (const [credTypeName, details] of Object.entries(node.credentials ?? {})) { + if (foundIssues[credTypeName]?.length) continue; + if (!details?.id || details.__aiGatewayManaged) continue; + + const credential = credentialsStore.getCredentialById(details.id); + if (!credential?.isResolvable) continue; + + if (!credential.connectedByMe) { + foundIssues[credTypeName] = [ + i18n.baseText('nodeIssues.credentials.privateNotConnected', { + interpolate: { name: credential.name }, + }), + ]; + } + } + } + function getNodeCredentialIssues( node: INodeUi, nodeType?: INodeTypeDescription, @@ -561,6 +585,8 @@ export function useNodeHelpers() { } } + collectPrivateCredentialIssues(node, foundIssues); + // TODO: Could later check also if the node has access to the credentials if (Object.keys(foundIssues).length === 0) { return null; diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 33226781de5..6fd29ee880c 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -1334,6 +1334,10 @@ async function oAuthCredentialAuthorize() { void refreshConnectedByMe(credential.id); + void credentialsStore.fetchAllCredentials().then(() => { + nodeHelpers.updateNodesCredentialsIssues(); + }); + // Close the window if (oauthPopup) { oauthPopup.close(); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts index c59a709e15a..965e4d99949 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.test.ts @@ -463,7 +463,7 @@ describe('NodeCredentials', () => { isResolvable: true, }); - it('should show dynamic icon in dropdown for resolvable credentials', async () => { + it('should show private badge in dropdown for resolvable credentials', async () => { ndvStore.activeNode = httpNode; credentialsStore.state.credentials = { c8vqdPpPClh4TgIO: createCredential({ isResolvable: true }), @@ -475,10 +475,10 @@ describe('NodeCredentials', () => { await userEvent.click(credentialsSelect); - expect(screen.queryByTestId('credential-option-dynamic-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('credential-option-private-badge')).toBeInTheDocument(); }); - it('should not show dynamic icon in dropdown for non-resolvable credentials', async () => { + it('should not show private badge in dropdown for non-resolvable credentials', async () => { ndvStore.activeNode = httpNode; credentialsStore.state.credentials = { c8vqdPpPClh4TgIO: createCredential({ isResolvable: false }), @@ -490,7 +490,7 @@ describe('NodeCredentials', () => { await userEvent.click(credentialsSelect); - expect(screen.queryByTestId('credential-option-dynamic-icon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('credential-option-private-badge')).not.toBeInTheDocument(); }); function setupResolvableCredential() { @@ -503,12 +503,12 @@ describe('NodeCredentials', () => { credentialsStore.getCredentialById = vi.fn().mockReturnValue(resolvableCredential); } - it('should show dynamic indicator next to selected resolvable credential', async () => { + it('should show private indicator next to selected resolvable credential', async () => { setupResolvableCredential(); renderComponent(); - expect(screen.queryByTestId('node-credential-dynamic-icon')).toBeInTheDocument(); + expect(screen.queryByTestId('node-credential-private-icon')).toBeInTheDocument(); }); it('should show warning when resolvable credential selected but workflow has no resolver', async () => { @@ -1431,4 +1431,93 @@ describe('NodeCredentials', () => { expect(payload.properties.credentials['googlePalmApi']).toBeUndefined(); }); }); + + describe('private credential badge and callout', () => { + const privateCredential = createCredential({ + id: 'private-cred-id', + name: 'My Slack', + type: 'openAiApi', + isResolvable: true, + }); + + const notionNode: INodeUi = { + ...httpNode, + id: 'notion-node-id', + name: 'Notion', + type: 'n8n-nodes-base.notion', + credentials: { openAiApi: { id: 'private-cred-id', name: 'My Slack' } }, + parameters: {}, + }; + + beforeEach(() => { + ndvStore.activeNode = notionNode; + credentialsStore.state.credentials = { + 'private-cred-id': privateCredential, + }; + }); + + it('renders the Private badge when selected credential is resolvable', async () => { + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + expect(screen.getByTestId('node-credential-private-icon')).toBeInTheDocument(); + }); + + it('does not render the Private badge for a static credential', async () => { + credentialsStore.state.credentials = { + c8vqdPpPClh4TgIO: createCredential({ isResolvable: false }), + }; + renderComponent({ props: { node: httpNode, overrideCredType: 'openAiApi' } }); + + expect(screen.queryByTestId('node-credential-private-icon')).not.toBeInTheDocument(); + }); + + it('renders the private callout when a private credential is selected', async () => { + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + expect(screen.getByTestId('node-credential-private-callout')).toBeInTheDocument(); + }); + + it('does not render the callout for a static credential', async () => { + credentialsStore.state.credentials = { + c8vqdPpPClh4TgIO: createCredential({ isResolvable: false }), + }; + renderComponent({ props: { node: httpNode, overrideCredType: 'openAiApi' } }); + + expect(screen.queryByTestId('node-credential-private-callout')).not.toBeInTheDocument(); + }); + + it('shows connected status row when connectedByMe is true', async () => { + credentialsStore.state.credentials = { + 'private-cred-id': { ...privateCredential, connectedByMe: true }, + }; + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + expect(screen.getByText('Your account is connected')).toBeInTheDocument(); + expect(screen.queryByTestId('node-credential-private-connect')).not.toBeInTheDocument(); + }); + + it('shows not-connected status row with Connect link when connectedByMe is false', async () => { + credentialsStore.state.credentials = { + 'private-cred-id': { ...privateCredential, connectedByMe: false }, + }; + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + expect(screen.getByText("Your account isn't connected yet.")).toBeInTheDocument(); + expect(screen.getByTestId('node-credential-private-connect')).toBeInTheDocument(); + }); + + it('clicking Connect link calls uiStore.openExistingCredential with the credential id', async () => { + credentialsStore.state.credentials = { + 'private-cred-id': { ...privateCredential, connectedByMe: false }, + }; + renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } }); + + await userEvent.click(screen.getByTestId('node-credential-private-connect')); + + expect(uiStore.openExistingCredential).toHaveBeenCalledWith( + 'private-cred-id', + expect.any(Object), + ); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue index bf6ec92eda8..939216c1271 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/NodeCredentials.vue @@ -173,6 +173,18 @@ function isCredentialResolvable(credentialType: string): boolean { return credential?.isResolvable === true; } +function getSelectedPrivateCredential(credentialType: string): ICredentialsResponse | null { + if (!isDynamicCredentialsEnabled.value) return null; + const id = selected.value[credentialType]?.id; + if (!id) return null; + const credential = credentialsStore.getCredentialById(id); + return credential?.isResolvable === true ? credential : null; +} + +function isPrivateConnected(credentialType: string): boolean { + return getSelectedPrivateCredential(credentialType)?.connectedByMe === true; +} + function showResolvableWarning(credentialType: string): boolean { return isCredentialResolvable(credentialType) && !hasWorkflowResolver.value; } @@ -821,12 +833,16 @@ async function onQuickConnectSignIn(credentialTypeName: string) { - + + + + {{ i18n.baseText('credentials.private.badge') }} + + {{ item.typeDisplayName }} @@ -852,11 +868,11 @@ async function onQuickConnectSignIn(credentialTypeName: string) { - {{ i18n.baseText('credentials.dynamic.badge') }} + {{ i18n.baseText('credentials.private.badge') }} @@ -888,25 +904,60 @@ async function onQuickConnectSignIn(credentialTypeName: string) { /> - - - - - - + +
+ +
+ {{ i18n.baseText('credentials.private.callout.title') }} +
+ + +
+
+
+
+ + + + + + + @@ -1009,8 +1060,37 @@ async function onQuickConnectSignIn(credentialTypeName: string) { height: 18px; } -.resolverWarning { +.noticesContainer { + display: flex; + flex-direction: column; + gap: var(--spacing--3xs); margin-top: var(--spacing--2xs); + margin-bottom: var(--spacing--xs); + + :global(.notice) { + margin: 0; + } +} + +.privateStatusRow { + display: flex; + align-items: center; + gap: var(--spacing--3xs); + margin-top: var(--spacing--3xs); +} + +.privateNotConnectedText { + color: var(--color--text--tint-1); +} + +.privateNoticeContent { + display: flex; + gap: var(--spacing--xs); +} + +.privateNoticeIcon { + flex-shrink: 0; + margin-top: 1px; } .newCredential { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts index 636b4e676bf..17352e4baaa 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSettingsIcons.test.ts @@ -67,10 +67,11 @@ describe('CanvasNodeSettingsIcons', () => { credentialsStore = mockedStore(useCredentialsStore); workflowsStore.workflowId = WORKFLOW_ID; - workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(WORKFLOW_ID)); // Default: feature flag disabled mockFeatureFlag(false); + + workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(WORKFLOW_ID)); }); describe('dynamic credentials icon', () => {