mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 09:47:00 +02:00
feat(editor): Add private credential badge, callout, and not-connected validation in NDV (#31204)
This commit is contained in:
parent
bd2c1f45e3
commit
b8903064cf
|
|
@ -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..",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1334,6 +1334,10 @@ async function oAuthCredentialAuthorize() {
|
|||
|
||||
void refreshConnectedByMe(credential.id);
|
||||
|
||||
void credentialsStore.fetchAllCredentials().then(() => {
|
||||
nodeHelpers.updateNodesCredentialsIssues();
|
||||
});
|
||||
|
||||
// Close the window
|
||||
if (oauthPopup) {
|
||||
oauthPopup.close();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user