feat(editor): Add private credential badge, callout, and not-connected validation in NDV (#31204)

This commit is contained in:
Yuliia Pominchuk 2026-05-29 08:19:51 +02:00 committed by GitHub
parent bd2c1f45e3
commit b8903064cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 425 additions and 37 deletions

View File

@ -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..",

View File

@ -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<string, IUsedCredential> = {};
@ -52,6 +64,12 @@ describe('useNodeHelpers()', () => {
mockedStore(useWorkflowsStore).workflowId = 'workflow-id';
});
beforeEach(() => {
mockedUseDynamicCredentials.mockReturnValue({
isEnabled: computed(() => true),
} as ReturnType<typeof useDynamicCredentials>);
});
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> = {}): 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<Workflow>(), ['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<Workflow>(), ['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<Workflow>(), ['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<Workflow>(), ['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<Workflow>(), ['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<Workflow>(), ['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<typeof useDynamicCredentials>);
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<Workflow>(), ['parameters']);
expect(result?.credentials?.notionApi).toBeUndefined();
});
});
});

View File

@ -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;

View File

@ -1334,6 +1334,10 @@ async function oAuthCredentialAuthorize() {
void refreshConnectedByMe(credential.id);
void credentialsStore.fetchAllCredentials().then(() => {
nodeHelpers.updateNodesCredentialsIssues();
});
// Close the window
if (oauthPopup) {
oauthPopup.close();

View File

@ -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),
);
});
});
});

View File

@ -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) {
<template #content>{{
i18n.baseText('credentials.dynamic.tooltip')
}}</template>
<N8nIcon
icon="key-round"
size="medium"
:class="$style.dynamicIcon"
data-test-id="credential-option-dynamic-icon"
/>
<N8nBadge
theme="tertiary"
class="pl-3xs pr-3xs"
data-test-id="credential-option-private-badge"
>
<span :class="$style.dynamicBadgeText">
<N8nIcon icon="key-round" size="medium" />
{{ i18n.baseText('credentials.private.badge') }}
</span>
</N8nBadge>
</N8nTooltip>
</div>
<N8nText size="small">{{ item.typeDisplayName }}</N8nText>
@ -852,11 +868,11 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
<N8nBadge
theme="tertiary"
class="pl-3xs pr-3xs"
data-test-id="node-credential-dynamic-icon"
data-test-id="node-credential-private-icon"
>
<span :class="$style.dynamicBadgeText">
<N8nIcon icon="key-round" size="medium" />
{{ i18n.baseText('credentials.dynamic.badge') }}
{{ i18n.baseText('credentials.private.badge') }}
</span>
</N8nBadge>
</N8nTooltip>
@ -888,25 +904,60 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
/>
</div>
</div>
<N8nNotice
v-if="showResolvableWarning(type.name)"
theme="warning"
:class="$style.resolverWarning"
data-test-id="node-credential-resolver-warning"
<div
v-if="getSelectedPrivateCredential(type.name) || showResolvableWarning(type.name)"
:class="$style.noticesContainer"
>
<I18nT keypath="credentials.dynamic.warning.noResolver" tag="span" scope="global">
<template #workflowSettings>
<N8nLink @click="openWorkflowSettings">
{{ i18n.baseText('credentials.dynamic.warning.noResolver.workflowSettings') }}
</N8nLink>
</template>
<template v-if="dynamicCredentialsDocsUrl" #documentation>
<N8nLink :href="dynamicCredentialsDocsUrl" new-window>
{{ i18n.baseText('credentials.dynamic.warning.noResolver.documentation') }}
</N8nLink>
</template>
</I18nT>
</N8nNotice>
<N8nNotice
v-if="getSelectedPrivateCredential(type.name)"
:theme="isPrivateConnected(type.name) ? 'info' : 'warning'"
data-test-id="node-credential-private-callout"
>
<div :class="$style.privateNoticeContent">
<N8nIcon icon="user" size="small" :class="$style.privateNoticeIcon" />
<div>
<span>{{ i18n.baseText('credentials.private.callout.title') }}</span>
<div :class="$style.privateStatusRow">
<template v-if="isPrivateConnected(type.name)">
<N8nIcon icon="circle-check" color="success" size="small" />
<N8nText size="small">{{
i18n.baseText('credentials.private.callout.connected')
}}</N8nText>
</template>
<template v-else>
<N8nText size="small" :class="$style.privateNotConnectedText">{{
i18n.baseText('credentials.private.callout.notConnected')
}}</N8nText>
<N8nLink
data-test-id="node-credential-private-connect"
@click="editCredential(type.name)"
>
{{ i18n.baseText('credentials.private.callout.connect') }}
</N8nLink>
</template>
</div>
</div>
</div>
</N8nNotice>
<N8nNotice
v-if="showResolvableWarning(type.name)"
theme="warning"
data-test-id="node-credential-resolver-warning"
>
<I18nT keypath="credentials.dynamic.warning.noResolver" tag="span" scope="global">
<template #workflowSettings>
<N8nLink @click="openWorkflowSettings">
{{ i18n.baseText('credentials.dynamic.warning.noResolver.workflowSettings') }}
</N8nLink>
</template>
<template v-if="dynamicCredentialsDocsUrl" #documentation>
<N8nLink :href="dynamicCredentialsDocsUrl" new-window>
{{ i18n.baseText('credentials.dynamic.warning.noResolver.documentation') }}
</N8nLink>
</template>
</I18nT>
</N8nNotice>
</div>
</N8nInputLabel>
</div>
</div>
@ -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 {

View File

@ -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', () => {