fix(editor): Polish private credential pills, callout, and banners (#31604)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-06-03 13:45:15 +02:00 committed by GitHub
parent 8eda311630
commit 7e83c7b591
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 188 additions and 117 deletions

View File

@ -1141,13 +1141,14 @@
"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.private.tooltip": "This credential uses a resolver to pick the right account at runtime based on who runs the workflow.",
"credentials.private.tooltipTitle": "Private credentials",
"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.connectTitle": "Connect your account",
"credentials.private.callout.connectDescription": "This workflow runs as you. Connect your {service} account to continue.",
"credentials.private.callout.connectDescriptionGeneric": "This workflow runs as you. Connect your account to continue.",
"credentials.private.callout.connect": "Connect",
"credentials.search.placeholder": "Search credentials...",
"credentials.filters.type": "Type",
@ -3723,7 +3724,6 @@
"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

@ -807,6 +807,7 @@ describe('useNodeHelpers()', () => {
const NOTION_API = 'notionApi';
const MANUAL_TRIGGER = 'n8n-nodes-base.manualTrigger';
const MANUAL_CHAT_TRIGGER = '@n8n/n8n-nodes-langchain.manualChatTrigger';
const CHAT_TRIGGER = '@n8n/n8n-nodes-langchain.chatTrigger';
const WEBHOOK_TRIGGER = 'n8n-nodes-base.webhook';
const notionNodeType: INodeTypeDescription = {
@ -863,7 +864,7 @@ describe('useNodeHelpers()', () => {
});
describe('not connected', () => {
it('emits privateNotConnected when declared-credential node has private cred not connected', () => {
it('emits no issue 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]);
@ -873,8 +874,9 @@ describe('useNodeHelpers()', () => {
'parameters',
]);
expect(result?.credentials?.[NOTION_API]).toBeDefined();
expect(result?.credentials?.[NOTION_API][0]).toContain('My Notion');
// An unconnected private credential is surfaced as a warning in the UI,
// not as a node issue.
expect(result?.credentials).toBeUndefined();
});
it('emits no issue when declared-credential node has private cred connected', () => {
@ -890,7 +892,7 @@ describe('useNodeHelpers()', () => {
expect(result?.credentials).toBeUndefined();
});
it('emits privateNotConnected for predefined-OAuth credential (HTTP Request, not in node type credentials array)', () => {
it('emits no issue for predefined-OAuth private credential not connected (HTTP Request, not in node type credentials array)', () => {
mockedStore(useNodeTypesStore).getNodeType = vi.fn().mockReturnValue(httpRequestNodeType);
const cred = makePrivateCred({ type: 'slackOAuth2Api', connectedByMe: false });
@ -908,8 +910,7 @@ describe('useNodeHelpers()', () => {
const { getNodeIssues } = useNodeHelpers();
const result = getNodeIssues(httpRequestNodeType, node, mock<Workflow>(), ['parameters']);
expect(result?.credentials?.slackOAuth2Api).toBeDefined();
expect(result?.credentials?.slackOAuth2Api[0]).toContain('My Notion');
expect(result?.credentials?.slackOAuth2Api).toBeUndefined();
});
it('emits no issue for static (non-resolvable) credential regardless of connectedByMe', () => {
@ -1006,6 +1007,16 @@ describe('useNodeHelpers()', () => {
expect(result).toBeNull();
});
it('does not warn when a private credential is used under a chat trigger', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(CHAT_TRIGGER)];
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result).toBeNull();
});
it('warns when a private credential is used under a non-manual trigger', () => {
mockConnectedPrivateCred(true);
mockDocumentStore.workflowTriggerNodes = [buildTriggerNode(WEBHOOK_TRIGGER)];
@ -1199,7 +1210,7 @@ describe('useNodeHelpers()', () => {
});
describe('precedence', () => {
it('emits only privateNotConnected when both rules would fire (not-connected wins)', () => {
it('emits no issue for a not-connected private credential even under an incompatible trigger', () => {
const cred = makePrivateCred({ connectedByMe: false });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(cred);
mockedStore(useCredentialsStore).getCredentialsByType = vi.fn().mockReturnValue([cred]);
@ -1208,9 +1219,9 @@ describe('useNodeHelpers()', () => {
const { getNodeCredentialIssues } = useNodeHelpers();
const result = getNodeCredentialIssues(buildNotionNode(), notionNodeType);
expect(result?.credentials?.[NOTION_API]).toHaveLength(1);
expect(result?.credentials?.[NOTION_API][0]).toContain('My Notion');
expect(result?.credentials?.[NOTION_API][0]).not.toContain('manually triggered workflows');
// The not-connected state is surfaced as a UI warning; the manual-trigger
// requirement only applies once the user has connected their account.
expect(result).toBeNull();
});
});
});

View File

@ -436,13 +436,10 @@ export function useNodeHelpers() {
const credential = credentialsStore.getCredentialById(details.id);
if (!credential?.isResolvable) continue;
if (!credential.connectedByMe) {
foundIssues[credTypeName] = [
i18n.baseText('nodeIssues.credentials.privateNotConnected', {
interpolate: { name: credential.name },
}),
];
} else if (incompatibleTrigger) {
// An unconnected private credential is a missing setup step, not a hard
// error — it's surfaced as a warning via the credential callout/banner in
// the UI rather than a node issue, so we don't add it here.
if (credential.connectedByMe && incompatibleTrigger) {
foundIssues[credTypeName] = [
i18n.baseText('nodeIssues.credentials.privateRequiresManualTrigger'),
];

View File

@ -372,7 +372,7 @@ describe('CredentialCard', () => {
expect(getByTestId('credential-card-connect')).toBeInTheDocument();
});
it('should show Connected label when private and connected', () => {
it('should not show Connect button or Connected label when private and connected', () => {
const data = createCredential({
isResolvable: true,
connectedByMe: true,
@ -381,10 +381,10 @@ describe('CredentialCard', () => {
const { getByTestId, queryByTestId } = renderComponent({ props: { data } });
// Once connected, a private credential is just a regular credential with a
// Private label — no connect prompt and no separate connected state.
expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument();
const connectedLabel = getByTestId('credential-card-connected');
expect(connectedLabel).toBeInTheDocument();
expect(connectedLabel).toHaveTextContent('Connected');
expect(queryByTestId('credential-card-connected')).not.toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});

View File

@ -79,13 +79,6 @@ const isPrivateUnconnected = computed(
credentialPermissions.value.update === true,
);
const isPrivateConnected = computed(
() =>
isDynamicCredentialsEnabled.value &&
props.data.isResolvable === true &&
props.data.connectedByMe === true,
);
const actions = computed(() => {
const items = [
{
@ -258,7 +251,7 @@ function moveResource() {
data-test-id="credential-card-dynamic"
>
<span :class="$style.dynamicBadgeText">
<N8nIcon icon="key-round" size="medium" />
<N8nIcon icon="key-round" size="small" />
{{ locale.baseText('credentials.private.badge') }}
</span>
</N8nBadge>
@ -308,16 +301,6 @@ function moveResource() {
{{ locale.baseText('credentials.item.connect') }}
</N8nButton>
</N8nTooltip>
<span
v-else-if="isPrivateConnected"
:class="$style.connectedLabel"
data-test-id="credential-card-connected"
>
<N8nIcon icon="circle-check" size="small" color="success" />
<N8nText size="small" color="success">
{{ locale.baseText('credentials.item.connected') }}
</N8nText>
</span>
<N8nActionToggle
data-test-id="credential-card-actions"
:actions="actions"
@ -367,18 +350,13 @@ function moveResource() {
cursor: default;
}
.connectedLabel {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
}
.dynamicBadgeText {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
font-size: var(--font-size--3xs);
height: 18px;
font-size: var(--font-size--2xs);
line-height: 1;
vertical-align: middle;
}
.tooltipContent {

View File

@ -411,13 +411,7 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
>
<template #button>
<div :class="$style.bannerActions">
<template v-if="isGoogleOAuthType">
<p
:class="$style.googleReconnectLabel"
v-text="`${i18n.baseText('credentialEdit.credentialConfig.reconnect')}:`"
/>
<GoogleAuthButton @click="$emit('oauth')" />
</template>
<GoogleAuthButton v-if="isGoogleOAuthType" @click="$emit('oauth')" />
<QuickConnectButton
v-else
size="small"
@ -429,8 +423,8 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
/>
<N8nButton
v-if="showDisconnectButton"
variant="subtle"
size="small"
variant="outline"
:size="isGoogleOAuthType ? 'xlarge' : 'small'"
:label="i18n.baseText('credentialEdit.credentialConfig.disconnect')"
data-test-id="oauth-disconnect-button"
@click="$emit('disconnect')"
@ -570,10 +564,6 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
}
}
.googleReconnectLabel {
margin-right: var(--spacing--3xs);
}
.bannerActions {
display: flex;
align-items: center;

View File

@ -572,7 +572,14 @@ async function beforeClose() {
{ cancelButtonText: i18n.baseText(`${I18N_PREFIX}.beforeClose1.cancelButtonText`) },
);
keepEditing = confirmAction === MODAL_CONFIRM;
} else if (credentialPermissions.value.update && isOAuthType.value && !isOAuthConnected.value) {
} else if (
credentialPermissions.value.update &&
isOAuthType.value &&
!isOAuthConnected.value &&
// Private credentials are only the reusable "blueprint" connecting is a
// per-user step done later, so we don't prompt to connect before closing.
!isResolvable.value
) {
const confirmAction = await confirmModal('beforeClose2', undefined, {
cancelButtonText: i18n.baseText(`${I18N_PREFIX}.beforeClose2.cancelButtonText`),
});

View File

@ -6,6 +6,7 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import { SYSTEM_RESOLVER_ID } from '@n8n/api-types';
import type { FrontendSettings } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions';
import NodeCredentials from './NodeCredentials.vue';
@ -1478,17 +1479,17 @@ describe('NodeCredentials', () => {
expect(screen.queryByTestId('node-credential-private-connect')).not.toBeInTheDocument();
});
it('shows not-connected status row with Connect link when connectedByMe is false', async () => {
it('shows the connect prompt with a Connect button 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.getByText('Connect your account')).toBeInTheDocument();
expect(screen.getByTestId('node-credential-private-connect')).toBeInTheDocument();
});
it('hides the Connect link when the user lacks update permission', async () => {
it('hides the Connect button when the user lacks update permission', async () => {
credentialsStore.state.credentials = {
'private-cred-id': {
...privateCredential,
@ -1498,11 +1499,11 @@ describe('NodeCredentials', () => {
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
expect(screen.getByText("Your account isn't connected yet.")).toBeInTheDocument();
expect(screen.getByText('Connect your account')).toBeInTheDocument();
expect(screen.queryByTestId('node-credential-private-connect')).not.toBeInTheDocument();
});
it('clicking Connect link calls uiStore.openExistingCredential with the credential id', async () => {
it('clicking the Connect button calls uiStore.openExistingCredential with the credential id', async () => {
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: false },
};
@ -1515,5 +1516,25 @@ describe('NodeCredentials', () => {
expect.any(Object),
);
});
it('still renders the callout when the workflow uses the default (system) resolver', async () => {
workflowDocumentStore.mergeSettings({ credentialResolverId: SYSTEM_RESOLVER_ID });
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: false },
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
expect(screen.getByTestId('node-credential-private-callout')).toBeInTheDocument();
});
it('hides the callout when the workflow uses a non-default resolver', async () => {
workflowDocumentStore.mergeSettings({ credentialResolverId: 'slack-resolver' });
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: false },
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
expect(screen.queryByTestId('node-credential-private-callout')).not.toBeInTheDocument();
});
});
});

View File

@ -37,6 +37,7 @@ import { isEmpty } from '@/app/utils/typesUtils';
import { getResourcePermissions } from '@n8n/permissions';
import { useNodeCredentialOptions } from '../composables/useNodeCredentialOptions';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { SYSTEM_RESOLVER_ID } from '@n8n/api-types';
import { useAiGateway } from '@/app/composables/useAiGateway';
import AiGatewaySelector from '@/app/components/AiGatewaySelector.vue';
@ -47,7 +48,6 @@ import {
N8nInput,
N8nInputLabel,
N8nLink,
N8nNotice,
N8nOption,
N8nSelect,
N8nText,
@ -185,6 +185,16 @@ function canConnectPrivateCredential(credentialType: string): boolean {
return getResourcePermissions(credential?.scopes).credential.update === true;
}
// The connect / connected callout is only relevant when the workflow uses the
// default (system) resolver, where resolution maps to the n8n user's own
// connection. With a custom resolver (e.g. Slack, OAuth) the runtime account is
// chosen by the resolver, not the n8n user, so their own connection state is
// irrelevant and we don't surface it.
const isDefaultResolver = computed(() => {
const resolverId = workflowDocumentStore?.value.settings?.credentialResolverId;
return !resolverId || resolverId === SYSTEM_RESOLVER_ID;
});
watch(
() => props.node.parameters,
(newValue, oldValue) => {
@ -828,7 +838,7 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
data-test-id="credential-option-private-badge"
>
<span :class="$style.dynamicBadgeText">
<N8nIcon icon="key-round" size="medium" />
<N8nIcon icon="key-round" size="small" />
{{ i18n.baseText('credentials.private.badge') }}
</span>
</N8nBadge>
@ -860,7 +870,7 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
data-test-id="node-credential-private-icon"
>
<span :class="$style.dynamicBadgeText">
<N8nIcon icon="key-round" size="medium" />
<N8nIcon icon="key-round" size="small" />
{{ i18n.baseText('credentials.private.badge') }}
</span>
</N8nBadge>
@ -893,39 +903,60 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
/>
</div>
</div>
<div v-if="getSelectedPrivateCredential(type.name)" :class="$style.noticesContainer">
<N8nNotice
v-if="getSelectedPrivateCredential(type.name)"
:theme="isPrivateConnected(type.name) ? 'info' : 'warning'"
<div
v-if="getSelectedPrivateCredential(type.name) && isDefaultResolver"
:class="$style.noticesContainer"
>
<div
v-if="isPrivateConnected(type.name)"
:class="$style.privateConnectedNotice"
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
v-if="canConnectPrivateCredential(type.name)"
data-test-id="node-credential-private-connect"
@click="editCredential(type.name)"
>
{{ i18n.baseText('credentials.private.callout.connect') }}
</N8nLink>
</template>
</div>
<span :class="$style.privateNoticeIconBadge">
<N8nIcon icon="user" size="medium" />
</span>
<div>
<N8nText size="small">{{
i18n.baseText('credentials.private.callout.title')
}}</N8nText>
<div :class="$style.privateStatusRow">
<N8nIcon icon="circle-check" color="success" size="small" />
<N8nText size="small" color="success">{{
i18n.baseText('credentials.private.callout.connected')
}}</N8nText>
</div>
</div>
</N8nNotice>
</div>
<div
v-else
:class="$style.privateConnectPrompt"
data-test-id="node-credential-private-callout"
>
<span :class="$style.privateNoticeIconBadge">
<N8nIcon icon="user" size="medium" />
</span>
<div :class="$style.privateConnectText">
<N8nText bold>{{
i18n.baseText('credentials.private.callout.connectTitle')
}}</N8nText>
<N8nText size="small">{{
getServiceName(type.name)
? i18n.baseText('credentials.private.callout.connectDescription', {
interpolate: { service: getServiceName(type.name) },
})
: i18n.baseText('credentials.private.callout.connectDescriptionGeneric')
}}</N8nText>
</div>
<N8nButton
v-if="canConnectPrivateCredential(type.name)"
:class="$style.privateConnectButton"
variant="outline"
size="small"
:label="i18n.baseText('credentials.private.callout.connect')"
data-test-id="node-credential-private-connect"
@click="editCredential(type.name)"
/>
</div>
</div>
</N8nInputLabel>
</div>
@ -1025,8 +1056,9 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
font-size: var(--font-size--3xs);
height: 18px;
font-size: var(--font-size--2xs);
line-height: 1;
vertical-align: middle;
}
.noticesContainer {
@ -1035,10 +1067,6 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
gap: var(--spacing--3xs);
margin-top: var(--spacing--2xs);
margin-bottom: var(--spacing--xs);
:global(.notice) {
margin: 0;
}
}
.privateStatusRow {
@ -1048,18 +1076,56 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
margin-top: var(--spacing--3xs);
}
.privateNotConnectedText {
color: var(--color--text--tint-1);
}
.privateNoticeContent {
.privateConnectedNotice {
display: flex;
gap: var(--spacing--xs);
align-items: center;
gap: var(--spacing--sm);
padding: var(--spacing--xs) var(--spacing--sm);
border: var(--border);
border-radius: var(--radius);
color: var(--color--text);
.privateNoticeIconBadge {
background-color: var(--color--foreground--tint-1);
color: var(--color--text--tint-1);
}
}
.privateNoticeIcon {
.privateConnectPrompt {
display: flex;
align-items: center;
gap: var(--spacing--sm);
padding: var(--spacing--xs) var(--spacing--sm);
border: var(--border-width) var(--border-style) var(--callout--border-color--warning);
border-radius: var(--radius);
background-color: var(--callout--color--background--warning);
color: var(--color--text);
.privateNoticeIconBadge {
background-color: var(--color--warning--tint-1);
color: var(--color--warning);
}
}
.privateNoticeIconBadge {
display: flex;
flex-shrink: 0;
margin-top: 1px;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
}
.privateConnectText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.privateConnectButton {
flex-shrink: 0;
margin-left: auto;
}
.newCredential {

View File

@ -447,7 +447,7 @@ describe('CredentialsView', () => {
expect(getByTestId('card-badge')).toBeInTheDocument();
});
it('renders the Connected label for connected private credentials', () => {
it('shows no connect prompt or connected label for connected private credentials', () => {
enableDynamicCredentials();
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
@ -457,7 +457,7 @@ describe('CredentialsView', () => {
const { getByTestId, queryByTestId } = renderComponent();
expect(queryByTestId('credential-card-connect')).not.toBeInTheDocument();
expect(getByTestId('credential-card-connected')).toBeInTheDocument();
expect(queryByTestId('credential-card-connected')).not.toBeInTheDocument();
expect(getByTestId('card-badge')).toBeInTheDocument();
});

View File

@ -126,6 +126,7 @@ export const MINIMAX_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.minimax';
export const MANUAL_TRIGGER_NODE_TYPES: readonly string[] = [
MANUAL_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
];
export const AI_VENDOR_NODE_TYPES = [