feat(editor): Show restricted node types in the nodes panel and search (no-changelog) (#38865)

This commit is contained in:
Theodore Nastos 2026-09-21 11:35:12 +00:00 committed by GitHub
parent c3432699aa
commit 546aa598a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 970 additions and 40 deletions

View File

@ -41,6 +41,14 @@ const sampleItems = [
section: 'Actions',
handler: () => console.log('Duplicating workflow'),
},
{
id: 'publish-workflow',
title: 'Publish workflow (no permission)',
icon: { html: '🔒' },
section: 'Actions',
disabled: true,
handler: () => console.log('Never runs: the item is disabled'),
},
// Navigation section
{

View File

@ -148,6 +148,30 @@ describe('components', () => {
);
});
it('does not run a disabled item on click or Enter, and stays open', async () => {
const onCreate = vi.fn();
const items = createSampleItems().map((it) =>
it.id === 'create' ? { ...it, handler: onCreate, disabled: true } : it,
);
render(N8nCommandBar, { props: { items } });
await openCommandBar();
const item = screen.getByText('Create new workflow');
expect(item.closest('[aria-disabled="true"]')).not.toBeNull();
await fireEvent.click(item);
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument();
// Filter so the disabled item is the highlighted one, then press Enter on it.
const input = screen.getByPlaceholderText('Type a command...');
await fireEvent.update(input, 'Create new');
await fireEvent.keyDown(input, { key: 'Enter' });
expect(onCreate).not.toHaveBeenCalled();
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument();
});
it('closes when clicking outside the command bar', async () => {
render(N8nCommandBar, { props: { items: createSampleItems() } });
await openCommandBar();

View File

@ -187,6 +187,8 @@ const navigateBack = () => {
};
const selectItem = (item: CommandBarItem) => {
if (item.disabled) return;
if (item.children) {
navigateToChildren(item);
return;
@ -234,7 +236,7 @@ const handleKeydown = (event: KeyboardEvent) => {
case 'ArrowRight':
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
const selectedItem = flattenedItems.value[selectedIndex.value];
if (selectedItem.children) {
if (selectedItem.children && !selectedItem.disabled) {
event.preventDefault();
void navigateToChildren(selectedItem);
}

View File

@ -31,7 +31,8 @@ const handleMouseLeave = () => {
<div
:key="item.id"
:data-item-id="item.id"
:class="[$style.item, { [$style.selected]: isSelected }]"
:class="[$style.item, { [$style.selected]: isSelected, [$style.disabled]: item.disabled }]"
:aria-disabled="item.disabled || undefined"
@click.stop="handleSelect"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@ -79,6 +80,15 @@ const handleMouseLeave = () => {
&.selected {
background-color: var(--command-bar-item--color--background--hover);
}
&.disabled {
cursor: not-allowed;
.icon,
.content {
opacity: 0.45;
}
}
}
.icon {

View File

@ -11,4 +11,5 @@ export interface CommandBarItem {
placeholder?: string;
hasMoreChildren?: boolean;
matchAnySearchTerm?: boolean;
disabled?: boolean;
}

View File

@ -1,5 +1,6 @@
import type { StoryFn } from '@storybook/vue3-vite';
import N8nIcon from '../N8nIcon/Icon.vue';
import N8nNodeCreatorNode from './NodeCreatorNode.vue';
export default {
@ -19,6 +20,7 @@ const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
props: Object.keys(argTypes),
components: {
N8nIcon,
N8nNodeCreatorNode,
},
template: `
@ -26,6 +28,9 @@ const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
<template #icon>
<img src="https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/cartman.svg" />
</template>
<template v-if="args.disabled" #trailing>
<n8n-icon icon="lock" size="small" title="Restricted" />
</template>
</n8n-node-creator-node>
`,
});
@ -66,3 +71,18 @@ WithPanel.args = {
title: 'Node with panel',
isTrigger: true,
};
export const Restricted = DefaultTemplate.bind({});
Restricted.args = {
title: 'Gmail',
description: 'Fetches emails from Gmail and starts the workflow on specified polling intervals.',
disabled: true,
};
Restricted.parameters = {
docs: {
description: {
story:
'A node type a policy blocks. `disabled` fades the icon and text; the `trailing` slot keeps the lock at full strength. Rendered in the nodes panel by `NodeItem.vue`, which also anchors the explanation popover to the row.',
},
},
};

View File

@ -26,4 +26,26 @@ describe('N8nNodeCreatorNode', () => {
expect(className).not.toContain('info');
expect(className).not.toContain('danger');
});
it('greys the row and renders the trailing slot when disabled', () => {
const { container, getByText } = render(NodeCreatorNode, {
props: { title: 'Gmail', description: 'Send email', disabled: true },
slots: { trailing: '<span data-test-id="trailing">lock</span>' },
global: { stubs: ['N8nIcon'] },
});
expect(container.firstElementChild?.className).toContain('disabled');
expect(getByText('lock')).toBeInTheDocument();
});
it('shows the action arrow instead of the trailing slot when the row has actions', () => {
const { container, queryByText } = render(NodeCreatorNode, {
props: { title: 'Gmail', showActionArrow: true },
slots: { trailing: '<span>lock</span>' },
global: { stubs: ['N8nIcon'] },
});
expect(container.querySelector('button')).toBeInTheDocument();
expect(queryByText('lock')).not.toBeInTheDocument();
});
});

View File

@ -19,6 +19,7 @@ export interface Props {
isOfficial?: boolean;
hideNodeIcon?: boolean;
isNew?: boolean;
disabled?: boolean;
}
defineProps<Props>();
@ -27,7 +28,7 @@ defineEmits<{
tooltipClick: [e: MouseEvent];
}>();
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>();
defineSlots<{ icon: {}; extraDetails: {}; dragContent: {}; trailing: {} }>();
const { t } = useI18n();
</script>
@ -37,13 +38,14 @@ const { t } = useI18n();
:class="{
[$style.creatorNode]: true,
[$style.hasAction]: !showActionArrow,
[$style.disabled]: disabled,
}"
v-bind="$attrs"
>
<div v-if="!hideNodeIcon" :class="$style.nodeIcon">
<slot name="icon" />
</div>
<div>
<div :class="$style.body">
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-creator-item-name" v-text="title" />
<PreviewTag v-if="tag?.preview" size="small" :class="$style.previewTag" :text="tag.text" />
@ -85,6 +87,9 @@ const { t } = useI18n();
<button v-if="showActionArrow" :class="$style.panelIcon">
<N8nIcon icon="arrow-right" size="large" />
</button>
<div v-else-if="$slots.trailing" :class="$style.trailing">
<slot name="trailing" />
</div>
</div>
</template>
@ -103,6 +108,24 @@ const { t } = useI18n();
.creatorNode:hover .panelIcon {
color: var(--action--arrow--color--hover, var(--color--text--tint-1));
}
.disabled {
cursor: not-allowed;
// Fade the node, not the trailing slot, so a status icon there keeps full strength.
.nodeIcon,
.body {
opacity: 0.45;
}
}
.trailing {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing--2xs);
padding-right: var(--spacing--2xs);
color: var(--color--text--tint-1);
}
.previewTag {
margin-left: var(--spacing--2xs);
}

View File

@ -1 +1,4 @@
export const TOOLTIP_DELAY_MS = 500;
/** reka-ui stamps this on every popover and dialog layer it renders (N8nPopover, N8nDialog). */
export const OVERLAY_LAYER_SELECTOR = '[data-dismissable-layer]';

View File

@ -2436,6 +2436,9 @@
"typeAvailabilityPolicies.restrictedNode.contactAdmin": "Contact instance admin",
"typeAvailabilityPolicies.restrictedNode.contactAdmin.description": "Instance admins manage which nodes are available. Contact one of them to request access to '{nodeType}'.",
"typeAvailabilityPolicies.restrictedNode.contactAdmin.mailSubject": "Access request for the {nodeType} node",
"typeAvailabilityPolicies.restrictedNode.scope.instance": "Restricted on this instance",
"typeAvailabilityPolicies.restrictedNode.scope.project": "Restricted in this project",
"typeAvailabilityPolicies.restrictedNode.popover.description": "An administrator blocked this node. To use it in your workflows, contact an instance administrator for access.",
"typeAvailabilityPolicies.contactAdmin.title": "Instance admins",
"typeAvailabilityPolicies.contactAdmin.empty": "No instance admin is listed here. Ask the person who manages this n8n instance.",
"nodeView.addNode": "Add node",

View File

@ -1,11 +1,16 @@
import { deepCopy, type ICredentialType, type INodeTypeDescription } from 'n8n-workflow';
import { CREDENTIAL_ONLY_NODE_PREFIX } from '../constants';
import { CREDENTIAL_ONLY_NODE_PREFIX, HTTP_REQUEST_NODE_TYPE } from '../constants';
import { i18n } from '@n8n/i18n';
export function isCredentialOnlyNodeType(nodeTypeName: string): boolean {
return nodeTypeName?.startsWith(CREDENTIAL_ONLY_NODE_PREFIX) ?? false;
}
/** A credential-only node is HTTP Request in disguise, so policy applies to the node it wraps. */
export function toPolicyNodeType(nodeTypeName: string): string {
return isCredentialOnlyNodeType(nodeTypeName) ? HTTP_REQUEST_NODE_TYPE : nodeTypeName;
}
export function getCredentialTypeName(nodeTypeName: string): string {
return nodeTypeName.split('.')[1];
}

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { IconOrEmoji, KeyboardShortcut } from '@n8n/design-system';
import { N8nKeyboardShortcut } from '@n8n/design-system';
import type { IconName, IconOrEmoji, KeyboardShortcut } from '@n8n/design-system';
import { N8nIcon, N8nKeyboardShortcut } from '@n8n/design-system';
import ProjectIcon from '@/features/collaboration/projects/components/ProjectIcon.vue';
interface Props {
@ -9,6 +9,8 @@ interface Props {
suffixIcon?: IconOrEmoji;
actionText?: string;
shortcut?: KeyboardShortcut;
/** A trailing status icon, such as a lock for an entry the user cannot run. */
icon?: IconName;
// passed from CommandBarItem
isSelected?: boolean;
@ -28,6 +30,7 @@ defineProps<Props>();
<span v-if="actionText && (isSelected || isHovered)" :class="$style.action">{{
actionText
}}</span>
<N8nIcon v-if="icon" :icon="icon" size="small" :class="$style.icon" />
<span v-if="shortcut" :class="$style.shortcut">
<N8nKeyboardShortcut
:keys="shortcut.keys"
@ -77,4 +80,8 @@ defineProps<Props>();
margin-left: auto;
flex-shrink: 0;
}
.icon {
color: var(--color--text--tint-1);
flex-shrink: 0;
}
</style>

View File

@ -1,6 +1,7 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useNodeCommands } from './useNodeCommands';
import { mockRestrictedNodeTypes } from '@/features/shared/nodeCreator/__tests__/utils';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -222,6 +223,73 @@ describe('useNodeCommands', () => {
expect(mockGenerateMergedNodesAndActionsFn).toHaveBeenCalled();
});
it('should list a restricted node last, disabled and locked, instead of hiding it', () => {
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
mergedNodes: [
createMockNodeType('n8n-nodes-base.gmail', 'Gmail'),
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
],
});
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
const { commands } = useNodeCommands({
lastQuery: ref(''),
activeNodeId: ref(null),
});
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
expect(children.map((child) => child.id)).toEqual([
'n8n-nodes-base.slack',
'n8n-nodes-base.gmail',
]);
expect(children[0].disabled).toBe(false);
expect(children[1].disabled).toBe(true);
const restrictedTitle = children[1].title;
expect(typeof restrictedTitle === 'object' && restrictedTitle.props?.icon).toBe('lock');
});
it('should disable a credential-only node when HTTP Request is restricted', () => {
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
mergedNodes: [
createMockNodeType('n8n-creds-base.sysdigApi', 'Sysdig'),
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
],
});
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
const { commands } = useNodeCommands({
lastQuery: ref(''),
activeNodeId: ref(null),
});
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
expect(children.map((child) => [child.id, child.disabled ?? false])).toEqual([
['n8n-nodes-base.slack', false],
['n8n-creds-base.sysdigApi', true],
]);
});
it('should disable a node type the policy restricts', () => {
mockGenerateMergedNodesAndActionsFn.mockReturnValue({
mergedNodes: [
createMockNodeType('n8n-nodes-base.httpRequest', 'HTTP Request'),
createMockNodeType('n8n-nodes-base.slack', 'Slack'),
],
});
mockRestrictedNodeTypes({ 'n8n-nodes-base.slack': 'instance' });
const { commands } = useNodeCommands({
lastQuery: ref(''),
activeNodeId: ref(null),
});
const children = commands.value.find((cmd) => cmd.id === 'add-node')?.children ?? [];
expect(children.map((child) => [child.id, child.disabled ?? false])).toEqual([
['n8n-nodes-base.httpRequest', false],
['n8n-nodes-base.slack', true],
]);
});
});
describe('open node command', () => {

View File

@ -5,6 +5,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useActionsGenerator } from '@/features/shared/nodeCreator/composables/useActionsGeneration';
import { isNodeItemRestricted } from '@/features/shared/nodeCreator/nodeCreator.utils';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import { type CommandBarItem } from '@n8n/design-system';
import type { CommandGroup } from '../types';
@ -53,24 +54,32 @@ export function useNodeCommands(options: {
(workflowPermissions.value[permission] === true && !isReadOnly.value && !isArchived.value) ||
!workflowsStore.isWorkflowSaved[workflowsStore.workflowId];
// Restricted types stay listed, like in the nodes panel: greyed, locked, last, and inert.
const mergedNodes = computed(() => {
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
const nodeTypes = nodeTypesStore.visibleNodeTypes;
return generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials).mergedNodes;
const nodes = generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials).mergedNodes;
const restricted = new Set(nodes.filter((node) => isNodeItemRestricted(node.name)));
return [...nodes.filter((node) => !restricted.has(node)), ...restricted];
});
const buildAddNodeCommand = (node: SimplifiedNodeType, isRoot: boolean): CommandBarItem => {
const { name, displayName } = node;
const title = isRoot ? `${i18n.baseText('generic.add')} ${displayName}` : displayName;
const titleText = isRoot ? `${i18n.baseText('generic.add')} ${displayName}` : displayName;
const section = isRoot
? i18n.baseText('commandBar.sections.nodes')
: i18n.baseText('commandBar.nodes.addNode');
const disabled = isNodeItemRestricted(name);
const title = disabled
? { component: CommandBarItemTitle, props: { title: titleText, icon: 'lock' } }
: titleText;
return {
id: name,
title,
section,
disabled,
keywords: [displayName],
icon: {
component: NodeIcon as Component,

View File

@ -1,3 +1,6 @@
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import { vi } from 'vitest';
import type {
SimplifiedNodeType,
ActionTypeDescription,
@ -13,6 +16,21 @@ import type {
} from '@/Interface';
import { v4 as uuidv4 } from 'uuid';
/**
* Makes the active pinia's policy store report these node types as restricted. Call after
* `setActivePinia` (or after rendering with the pinia the component uses).
*/
export function mockRestrictedNodeTypes(
restricted: Record<string, NodeTypeAvailabilityScope> = {},
): void {
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
(name) => {
const scope = restricted[name];
return scope ? { name, available: false, scope } : { name, available: true };
},
);
}
export const mockSimplifiedNodeType = (
overrides?: Partial<SimplifiedNodeType>,
): SimplifiedNodeType => ({

View File

@ -1,5 +1,7 @@
import type { Pinia } from 'pinia';
import { createPinia, setActivePinia } from 'pinia';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import {
AI_CATEGORY_OTHER_TOOLS,
@ -8,7 +10,7 @@ import {
MESSAGE_AN_AGENT_NODE_TYPE,
} from '@/app/constants';
import { createComponentRenderer } from '@/__tests__/render';
import { mockSimplifiedNodeType } from '../../__tests__/utils';
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
import { useViewStacks } from '../../composables/useViewStacks';
import NodeItem from './NodeItem.vue';
import type { AddedNodesAndConnections } from '@/Interface';
@ -213,4 +215,71 @@ describe('NodeItem', () => {
expect(getDescription(container)).toBe('Interact with Anthropic AI models');
});
});
describe('restricted node type', () => {
const gmail = () =>
mockSimplifiedNodeType({
name: 'n8n-nodes-base.gmail',
displayName: 'Gmail',
group: ['output'],
});
it('is greyed, locked, not draggable and has no action arrow', () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
const { container, getByTestId } = render({ pinia, props: { nodeType: gmail() } });
const row = getByTestId('node-creator-restricted-item');
expect(row.getAttribute('draggable')).toBe('false');
expect(row.className).toContain('disabled');
expect(getByTestId('node-restricted-icon')).toBeInTheDocument();
expect(container.querySelector('[data-icon="arrow-right"]')).not.toBeInTheDocument();
});
it('opens the explanation for the hovered row, anchored to the row', async () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
const { getByTestId } = render({ pinia, props: { nodeType: gmail() } });
expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument();
await userEvent.hover(getByTestId('node-creator-restricted-item'));
expect(await screen.findByText('Restricted on this instance')).toBeInTheDocument();
});
it('opens the explanation for the keyboard-active row with its scope', async () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'project' });
render({ pinia, props: { nodeType: gmail(), active: true } });
expect(await screen.findByText('Restricted in this project')).toBeInTheDocument();
});
it('treats a credential-only node like the HTTP Request node it wraps', () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
const { queryByTestId } = render({
pinia,
props: {
nodeType: mockSimplifiedNodeType({
name: 'n8n-creds-base.sysdigApi',
displayName: 'Sysdig',
group: ['output'],
}),
},
});
expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument();
});
it('renders a normal row when the type is not restricted', () => {
mockRestrictedNodeTypes();
const { container, queryByTestId } = render({ pinia, props: { nodeType: gmail() } });
expect(queryByTestId('node-creator-restricted-item')).not.toBeInTheDocument();
expect(queryByTestId('node-restricted-icon')).not.toBeInTheDocument();
expect(container.querySelector('[draggable="true"]')).toBeInTheDocument();
});
});
});

View File

@ -9,7 +9,7 @@ import {
MESSAGE_AN_AGENT_NODE_TYPE,
} from '@/app/constants';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/features/settings/communityNodes/communityNodes.constants';
import { computed, ref } from 'vue';
import { computed, ref, type ComponentPublicInstance } from 'vue';
import NodeIcon from '@/app/components/NodeIcon.vue';
import { getNodeIconSize } from '@/app/utils/nodeIcon';
@ -24,12 +24,14 @@ import { useI18n } from '@n8n/i18n';
import { useActions } from '../../composables/useActions';
import { useViewStacks } from '../../composables/useViewStacks';
import {
getNodeItemRestriction,
isNodePreviewKey,
removePreviewToken,
shouldShowCommunityNodeDetails,
} from '../../nodeCreator.utils';
import { N8nIcon, N8nNodeCreatorNode, N8nTooltip } from '@n8n/design-system';
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
export interface Props {
nodeType: SimplifiedNodeType;
subcategory?: string;
@ -51,6 +53,8 @@ const { isSubNodeType } = useNodeType({
nodeType: props.nodeType,
});
const nodeTypesStore = useNodeTypesStore();
const restriction = computed(() => getNodeItemRestriction(props.nodeType.name));
const rowRef = ref<ComponentPublicInstance | null>(null);
const dragging = ref(false);
const draggablePosition = ref({ x: -100, y: -100 });
@ -78,6 +82,8 @@ const description = computed<string>(() => {
});
const showActionArrow = computed(() => {
if (restriction.value) return false;
if (shouldShowCommunityNodeDetails(isCommunityNode.value, activeViewStack)) {
return true;
}
@ -97,9 +103,10 @@ const isSendAndWaitCategory = computed(
activeViewStack.subcategory === HITL_SUBCATEGORY ||
activeViewStack.rootView === HUMAN_IN_THE_LOOP_CATEGORY,
);
const dataTestId = computed(() =>
hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item',
);
const dataTestId = computed(() => {
if (restriction.value) return 'node-creator-restricted-item';
return hasActions.value ? 'node-creator-action-item' : 'node-creator-node-item';
});
const hasActions = computed(() => {
return nodeActions.value.length > 1 && !activeViewStack.hideActions;
@ -195,7 +202,9 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
<template>
<!-- Node Item is draggable only if it doesn't contain actions -->
<N8nNodeCreatorNode
:draggable="!showActionArrow"
ref="rowRef"
:draggable="!restriction && !showActionArrow"
:disabled="!!restriction"
:class="$style.nodeItem"
:description="description"
:title="displayName"
@ -253,6 +262,14 @@ function onCommunityNodeTooltipClick(event: MouseEvent) {
<N8nIcon size="small" :class="$style.icon" icon="box" />
</N8nTooltip>
</template>
<template v-if="restriction" #trailing>
<RestrictedNodePopover
:node-type-name="displayName"
:scope="restriction.scope"
:anchor="rowRef"
:active="active"
/>
</template>
<template #dragContent>
<div
v-show="dragging"

View File

@ -8,15 +8,19 @@ import {
AI_CATEGORY_MCP_NODES,
AI_MCP_TOOL_NODE_TYPE,
AI_OTHERS_NODE_CREATOR_VIEW,
HTTP_REQUEST_NODE_TYPE,
MESSAGE_AN_AGENT_NODE_TYPE,
REGULAR_NODE_CREATOR_VIEW,
REQUEST_NODE_FORM_URL,
SUGGEST_SERVICE_FORM_URL_REMOTE_CONFIG_KEY,
TRIGGER_NODE_CREATOR_VIEW,
} from '@/app/constants';
import type { NodeCreateElement } from '@/Interface';
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
import { useKeyboardNavigation } from '@/features/shared/nodeCreator/composables/useKeyboardNavigation';
import { createComponentRenderer } from '@/__tests__/render';
import { mockSimplifiedNodeType } from '../../__tests__/utils';
import { waitAllPromises } from '@n8n/frontend-test-utils';
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
import NodesMode from './NodesMode.vue';
const mockDocumentStoreState = {
@ -88,6 +92,10 @@ describe('NodesMode', () => {
setActivePinia(pinia);
});
afterEach(() => {
useKeyboardNavigation().detachKeydownEvent();
});
it('opens the agent picker sub-panel instead of adding the Message an Agent node', async () => {
useViewStacks().pushViewStack({
title: 'What happens next?',
@ -139,6 +147,98 @@ describe('NodesMode', () => {
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.set' }]]]);
});
describe('restricted node types', () => {
function setNodeElement(): NodeCreateElement {
return {
key: 'n8n-nodes-base.set',
type: 'node',
subcategory: '*',
properties: mockSimplifiedNodeType({
name: 'n8n-nodes-base.set',
displayName: 'Edit Fields',
group: ['transform'],
}),
};
}
// Browsing hides a restricted type, so the row is only there to click while searching.
function pushSearchStackWith(items: NodeCreateElement[]) {
useViewStacks().pushViewStack({
title: 'What happens next?',
mode: 'nodes',
rootView: REGULAR_NODE_CREATOR_VIEW,
hasSearch: true,
search: 'Edit Fields',
items,
});
}
async function pressEnterOnFirstItem() {
const keyboardNavigation = useKeyboardNavigation();
keyboardNavigation.attachKeydownEvent();
await keyboardNavigation.setActiveItemIndex(0);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
// Keyboard navigation refreshes its selectable items on a zero-delay timer.
await waitAllPromises();
await nextTick();
}
it('does not add a restricted node on click', async () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.set': 'instance' });
pushSearchStackWith([setNodeElement()]);
const { emitted } = render({ pinia });
await nextTick();
await userEvent.click(screen.getByText('Edit Fields'));
expect(emitted('nodeTypeSelected')).toBeUndefined();
});
it('does not add a restricted node on Enter', async () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.set': 'instance' });
pushSearchStackWith([setNodeElement()]);
const { emitted } = render({ pinia });
await nextTick();
await pressEnterOnFirstItem();
expect(emitted('nodeTypeSelected')).toBeUndefined();
});
it('drops a restricted node from the empty-search suggestions', async () => {
mockRestrictedNodeTypes({ [HTTP_REQUEST_NODE_TYPE]: 'instance' });
useViewStacks().pushViewStack({
title: 'What triggers this workflow?',
mode: 'nodes',
rootView: TRIGGER_NODE_CREATOR_VIEW,
search: 'missing node',
items: [],
});
const { emitted } = render({ pinia });
await nextTick();
expect(screen.getByText('No results for "missing node"')).toBeInTheDocument();
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Webhook'));
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.webhook' }]]]);
});
it('still adds an available node on Enter', async () => {
pushSearchStackWith([setNodeElement()]);
const { emitted } = render({ pinia });
await nextTick();
await pressEnterOnFirstItem();
expect(emitted('nodeTypeSelected')).toEqual([[[{ type: 'n8n-nodes-base.set' }]]]);
});
});
it('keeps the MCP client pinned once and shows the MCP empty state for no results', async () => {
const mcpClient = mcpClientElement();
const viewStacks = useViewStacks();

View File

@ -34,6 +34,8 @@ import {
getRootSearchCallouts,
shouldShowCommunityNodeDetails,
getHumanInTheLoopActions,
isNodeItemRestricted,
sinkRestrictedNodesLast,
} from '../../nodeCreator.utils';
import { useViewStacks } from '../../composables/useViewStacks';
import { useKeyboardNavigation } from '../../composables/useKeyboardNavigation';
@ -80,7 +82,7 @@ const workflowDocumentStore = injectWorkflowDocumentStore();
const communityNodesAndActions = computed(() => useNodeTypesStore().communityNodesAndActions);
const moreFromCommunity = computed(() => {
return filterAndSearchNodes(
const hits = filterAndSearchNodes(
communityNodesAndActions.value.mergedNodes,
activeViewStack.value.search ?? '',
{
@ -89,11 +91,15 @@ const moreFromCommunity = computed(() => {
aiConnectionType: activeViewStack.value.connectionType,
},
);
return sinkRestrictedNodesLast(hits, isNodeItemRestricted);
});
const isSearchResultEmpty = computed(() => {
// The pinned MCP client is a placeholder, not a result — unless it is restricted, in which
// case it is an ordinary search hit.
const hasNodeResults = (activeViewStack.value.items ?? []).some(
(item) => !isMcpCategory.value || item.key !== AI_MCP_TOOL_NODE_TYPE,
(item) =>
!isMcpCategory.value || item.key !== AI_MCP_TOOL_NODE_TYPE || isNodeItemRestricted(item.key),
);
return (
!hasNodeResults &&
@ -120,6 +126,10 @@ function getFilteredActions(
}
function onSelected(item: INodeCreateElement) {
// Insertion itself is refused in getAddedNodesAndConnections; this keeps a restricted
// node from opening its actions view.
if (item.type === 'node' && isNodeItemRestricted(item.key)) return;
if (item.type === 'subcategory') {
const subcategoryKey = camelCase(item.properties.title);
const title = i18n.baseText(`nodeCreator.subcategoryNames.${subcategoryKey}` as BaseTextKey);
@ -377,6 +387,8 @@ registerKeyHook('MainViewArrowLeft', {
<NoResults
:query="activeViewStack.search ?? ''"
:root-view="activeViewStack.rootView"
:suggest-webhook="!isNodeItemRestricted(WEBHOOK_NODE_TYPE)"
:suggest-http-request="!isNodeItemRestricted(HTTP_REQUEST_NODE_TYPE)"
@add-webhook-node="emit('nodeTypeSelected', [{ type: WEBHOOK_NODE_TYPE }])"
@add-http-node="emit('nodeTypeSelected', [{ type: HTTP_REQUEST_NODE_TYPE }])"
/>

View File

@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createPinia } from 'pinia';
import { onClickOutside } from '@vueuse/core';
import { OVERLAY_LAYER_SELECTOR } from '@n8n/design-system';
import { createComponentRenderer } from '@/__tests__/render';
import NodeCreator from './NodeCreator.vue';
@ -92,4 +94,14 @@ describe('NodeCreator', () => {
expect(mockFetchConfig).toHaveBeenCalledOnce();
});
it('does not close on clicks inside Element Plus modals or reka overlays', () => {
renderComponent();
expect(vi.mocked(onClickOutside)).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
{ ignore: expect.arrayContaining(['.el-overlay-dialog', OVERLAY_LAYER_SELECTOR]) },
);
});
});

View File

@ -19,11 +19,13 @@ import { useAiGateway } from '@/app/composables/useAiGateway';
import type { NodeTypeSelectedPayload } from '@/Interface';
import { onClickOutside } from '@vueuse/core';
import { N8nIconButton } from '@n8n/design-system';
import { N8nIconButton, OVERLAY_LAYER_SELECTOR } from '@n8n/design-system';
// elements that should not trigger onClickOutside
const OUTSIDE_CLICK_WHITELIST = [
// different modals
'.el-overlay-dialog',
// reka-ui overlays (N8nPopover, N8nDialog) teleport to body
OVERLAY_LAYER_SELECTOR,
];
export interface Props {

View File

@ -64,6 +64,50 @@ describe('NoResults', () => {
);
});
it('keeps the Webhook suggestion when only HTTP Request is restricted', async () => {
const wrapper = renderComponent({
props: { query: 'Gmail MCP', rootView: TRIGGER_NODE_CREATOR_VIEW, suggestHttpRequest: false },
});
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
expect(screen.getByTestId('node-creator-no-results')).toHaveTextContent(/our Webhook node/);
await fireEvent.click(screen.getByText('Webhook'));
expect(wrapper.emitted('addWebhookNode')).toHaveLength(1);
});
it('keeps the HTTP Request suggestion when only Webhook is restricted', async () => {
const wrapper = renderComponent({
props: { query: 'Gmail MCP', rootView: TRIGGER_NODE_CREATOR_VIEW, suggestWebhook: false },
});
expect(screen.queryByText('Webhook')).not.toBeInTheDocument();
expect(screen.getByTestId('node-creator-no-results')).toHaveTextContent(
/our HTTP Request node/,
);
await fireEvent.click(screen.getByText('HTTP Request'));
expect(wrapper.emitted('addHttpNode')).toHaveLength(1);
});
it('hides the suggestion line when both nodes are restricted', () => {
renderComponent({
props: {
query: 'Gmail MCP',
rootView: TRIGGER_NODE_CREATOR_VIEW,
suggestHttpRequest: false,
suggestWebhook: false,
},
});
expect(screen.getByText('No results for "Gmail MCP"')).toBeInTheDocument();
expect(screen.queryByText('Webhook')).not.toBeInTheDocument();
expect(screen.queryByText('HTTP Request')).not.toBeInTheDocument();
expect(screen.getByTestId('node-creator-no-results')).not.toHaveTextContent(
'Connect to almost any service',
);
});
it('does not suggest incompatible nodes in specialized AI views', () => {
renderComponent({
props: { query: 'Gmail MCP', rootView: AI_OTHERS_NODE_CREATOR_VIEW },

View File

@ -1,19 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { N8nLink } from '@n8n/design-system';
import { REGULAR_NODE_CREATOR_VIEW, TRIGGER_NODE_CREATOR_VIEW } from '@/app/constants';
import type { NodeFilterType } from '@/Interface';
defineProps<{
query: string;
rootView?: NodeFilterType;
}>();
const props = withDefaults(
defineProps<{
query: string;
rootView?: NodeFilterType;
suggestHttpRequest?: boolean;
suggestWebhook?: boolean;
}>(),
{ rootView: undefined, suggestHttpRequest: true, suggestWebhook: true },
);
const emit = defineEmits<{
addHttpNode: [];
addWebhookNode: [];
}>();
const i18n = useI18n();
const showWebhook = computed(
() => props.suggestWebhook && props.rootView === TRIGGER_NODE_CREATOR_VIEW,
);
const showHttpRequest = computed(
() =>
props.suggestHttpRequest &&
(props.rootView === REGULAR_NODE_CREATOR_VIEW || props.rootView === TRIGGER_NODE_CREATOR_VIEW),
);
</script>
<template>
@ -25,18 +40,23 @@ const i18n = useI18n();
})
}}
</p>
<p
v-if="rootView === REGULAR_NODE_CREATOR_VIEW || rootView === TRIGGER_NODE_CREATOR_VIEW"
:class="$style.action"
>
<p v-if="showWebhook || showHttpRequest" :class="$style.action">
{{ i18n.baseText('nodeCreator.noResults.connectUsingSuggestedNode') }}
<template v-if="rootView === TRIGGER_NODE_CREATOR_VIEW">
<template v-if="showWebhook">
<N8nLink size="small" theme="text" underline @click="emit('addWebhookNode')">
{{ i18n.baseText('nodeCreator.noResults.webhook') }}
</N8nLink>
{{ `${i18n.baseText('nodeCreator.noResults.or')} ` }}
<template v-if="showHttpRequest">{{
` ${i18n.baseText('nodeCreator.noResults.or')} `
}}</template>
</template>
<N8nLink size="small" theme="text" underline @click="emit('addHttpNode')">
<N8nLink
v-if="showHttpRequest"
size="small"
theme="text"
underline
@click="emit('addHttpNode')"
>
{{ i18n.baseText('nodeCreator.noResults.httpRequest') }}
</N8nLink>
{{ i18n.baseText('nodeCreator.noResults.node') }}

View File

@ -5,7 +5,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useViewStacks } from '@/features/shared/nodeCreator/composables/useViewStacks';
import { mockSimplifiedNodeType } from '../../__tests__/utils';
import { mockRestrictedNodeTypes, mockSimplifiedNodeType } from '../../__tests__/utils';
import NodesListPanel from './NodesListPanel.vue';
import { REGULAR_NODE_CREATOR_VIEW, DEBOUNCE_TIME } from '@/app/constants';
import type { ActionTypeDescription, NodeFilterType, SimplifiedNodeType } from '@/Interface';
@ -664,4 +664,64 @@ describe('NodesListPanel', () => {
expect(nodeCreatorStore.openingContext).toBe('replacement');
});
});
describe('restricted node types', () => {
const triggerNodes = [...Array(9).keys()].map(
(n) =>
mockSimplifiedNodeType({
name: `Trigger Node ${n}`,
displayName: `Trigger Node ${n}`,
group: ['trigger'],
}) as INodeTypeDescription,
);
function renderTriggerView() {
const { container } = getWrapperComponent(() => {
const { setMergeNodes } = useNodeCreatorStore();
setMergeNodes([...triggerNodes]);
return {};
});
return container;
}
it('hides a restricted type while browsing', async () => {
renderTriggerView();
mockRestrictedNodeTypes({ 'Trigger Node 3': 'instance' });
await nextTick();
screen.getByText('On app event').click();
await nextTick();
expect(screen.queryAllByTestId('item-iterator-item')).toHaveLength(8);
expect(screen.queryByText('Trigger Node 3')).not.toBeInTheDocument();
});
it('shows a restricted type last when searched for', async () => {
vi.useFakeTimers();
try {
renderTriggerView();
mockRestrictedNodeTypes({ 'Trigger Node 3': 'instance' });
await nextTick();
screen.getByText('On app event').click();
await nextTick();
await fireEvent.input(screen.getByTestId('node-creator-search-bar'), {
target: { value: 'Trigger Node' },
});
await vi.advanceTimersByTimeAsync(DEBOUNCE_TIME.INPUT.SEARCH + 1);
await nextTick();
const items = screen.getAllByTestId('item-iterator-item');
expect(items).toHaveLength(9);
expect(items[items.length - 1]).toHaveTextContent('Trigger Node 3');
expect(
items[items.length - 1].querySelector('[data-test-id="node-creator-restricted-item"]'),
).toBeInTheDocument();
expect(screen.getAllByTestId('node-creator-restricted-item')).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
});
});

View File

@ -193,7 +193,7 @@ watch(
<NodeItem
v-if="item.type === 'node' && !communityNode"
:node-type="item.properties"
:active="true"
:active="activeItemId === item.uuid"
:subcategory="item.subcategory"
/>

View File

@ -4,6 +4,7 @@ import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.s
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useActions } from './useActions';
import { mockRestrictedNodeTypes } from '../__tests__/utils';
import {
AGENT_NODE_TYPE,
AI_CATEGORY_LANGUAGE_MODELS,
@ -87,6 +88,16 @@ describe('useActions', () => {
});
describe('getAddedNodesAndConnections', () => {
it('adds nothing when any requested type is restricted', () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'instance' });
const { getAddedNodesAndConnections } = useActions();
expect(getAddedNodesAndConnections([{ type: 'n8n-nodes-base.gmail' }])).toEqual({
nodes: [],
connections: [],
});
});
test('should insert a manual trigger node when there are no triggers', () => {
const nodeCreatorStore = useNodeCreatorStore();

View File

@ -49,6 +49,7 @@ import {
removePreviewToken,
sortNodeCreateElements,
transformNodeType,
isNodeItemRestricted,
} from '../nodeCreator.utils';
import { useI18n } from '@n8n/i18n';
import { PUSH_NODES_OFFSET } from '@/app/utils/nodeViewUtils';
@ -317,7 +318,9 @@ export const useActions = () => {
}
function getAddedNodesAndConnections(addedNodes: AddedNode[]): AddedNodesAndConnections {
if (addedNodes.length === 0) {
// Every insert path ends here — click, Enter, drag, the "no results" links, actions mode —
// so this is where a restricted type is refused.
if (addedNodes.length === 0 || addedNodes.some((node) => isNodeItemRestricted(node.type))) {
return { nodes: [], connections: [] };
}

View File

@ -35,6 +35,9 @@ import {
extractAiGatewaySection,
finalizeItems,
flattenCreateElements,
isNodeItemRestricted,
sinkRestrictedNodesLast,
withoutRestrictedNodes,
groupItemsInSections,
isAINode,
nodeTypesToCreateElements,
@ -149,8 +152,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
popularity: nodePopularityMap,
}),
);
// The pinned client is read off baselineItems and skips the search results,
// so it needs its own restriction check; unpinned, it sinks with the rest.
const pinnedMcpClient =
stack.subcategory === AI_CATEGORY_MCP_NODES
stack.subcategory === AI_CATEGORY_MCP_NODES && !isNodeItemRestricted(AI_MCP_TOOL_NODE_TYPE)
? stack.baselineItems?.find((item) => item.key === AI_MCP_TOOL_NODE_TYPE)
: undefined;
const filteredSearchResults = pinnedMcpClient
@ -159,7 +164,10 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
const groupedNodes =
groupIfAiNodes(filteredSearchResults, stack, false) ?? filteredSearchResults;
const visibleNodes = pinnedMcpClient ? [pinnedMcpClient, ...groupedNodes] : groupedNodes;
const visibleNodes = sinkRestrictedNodesLast(
pinnedMcpClient ? [pinnedMcpClient, ...groupedNodes] : groupedNodes,
isNodeItemRestricted,
);
// Set the active index to the second item if there's a section
// as the first item is collapsable
stack.activeIndex = visibleNodes.some((node) => node.type === 'section') ? 1 : 0;
@ -167,15 +175,18 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
return visibleNodes;
}
// baselineItems stays unfiltered: it is also the search base, where restricted types stay findable.
const browseItems = withoutRestrictedNodes(stack.baselineItems, isNodeItemRestricted);
// Surface n8n Connect-powered nodes in a dedicated section at the top,
// extracted before grouping so they don't also land in the AI sections
if (showsAiGatewaySection(stack)) {
const extracted = extractAiGatewaySection(stack.baselineItems);
const extracted = extractAiGatewaySection(browseItems);
if (extracted) {
return finalizeItems([extracted.section, ...groupIfAiNodes(extracted.rest, stack, true)]);
}
}
return finalizeItems(groupIfAiNodes(stack.baselineItems, stack, true));
return finalizeItems(groupIfAiNodes(browseItems, stack, true));
});
const activeViewStack = computed<ViewStack>(() => {
@ -265,7 +276,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
return true;
});
return filteredSections;
return sinkRestrictedNodesLast(filteredSections, isNodeItemRestricted);
});
const itemsBySubcategory = computed(() => subcategorizeItems(nodeCreatorStore.mergedNodes));

View File

@ -22,10 +22,15 @@ import {
matchesAliasForConnectBoost,
nodeTypesToCreateElements,
mapToolSubcategoryIcon,
getNodeItemRestriction,
isNodeItemRestricted,
searchNodes,
sinkRestrictedNodesLast,
withoutRestrictedNodes,
} from './nodeCreator.utils';
import {
mockActionCreateElement,
mockRestrictedNodeTypes,
mockNodeCreateElement,
mockSectionCreateElement,
mockSimplifiedNodeType,
@ -1400,3 +1405,82 @@ describe('NodeCreator - utils', () => {
});
});
});
describe('node item restriction lookups', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
it('reports a restricted type with its scope', () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.gmail': 'project' });
expect(getNodeItemRestriction('n8n-nodes-base.gmail')).toMatchObject({ scope: 'project' });
expect(isNodeItemRestricted('n8n-nodes-base.gmail')).toBe(true);
expect(isNodeItemRestricted('n8n-nodes-base.slack')).toBe(false);
});
it('maps a credential-only node to the HTTP Request node it wraps', () => {
mockRestrictedNodeTypes({ 'n8n-nodes-base.httpRequest': 'instance' });
expect(isNodeItemRestricted('n8n-creds-base.sysdigApi')).toBe(true);
});
});
describe('restricted node helpers', () => {
const node = (key: string) => mockNodeCreateElement({ key });
const isRestricted = (name: string) => name.startsWith('blocked');
describe('withoutRestrictedNodes', () => {
it('drops restricted nodes at the top level', () => {
const items = [node('a'), node('blocked-1'), node('c')];
expect(withoutRestrictedNodes(items, isRestricted).map((i) => i.key)).toEqual(['a', 'c']);
});
it('drops restricted nodes inside a section and keeps the section', () => {
const section = mockSectionCreateElement({ children: [node('a'), node('blocked-1')] });
const [result] = withoutRestrictedNodes([section], isRestricted);
expect(result.type).toBe('section');
expect((result as SectionCreateElement).children.map((c) => c.key)).toEqual(['a']);
});
it('drops a section whose every child is restricted', () => {
const section = mockSectionCreateElement({ children: [node('blocked-1')] });
expect(withoutRestrictedNodes([node('a'), section], isRestricted).map((i) => i.key)).toEqual([
'a',
]);
});
});
describe('sinkRestrictedNodesLast', () => {
it('moves restricted nodes after every available node and keeps both orders', () => {
const items = [node('blocked-1'), node('a'), node('blocked-2'), node('b')];
expect(sinkRestrictedNodesLast(items, isRestricted).map((i) => i.key)).toEqual([
'a',
'b',
'blocked-1',
'blocked-2',
]);
});
it('sinks inside a section and keeps the section in place', () => {
const section = mockSectionCreateElement({
key: 'section',
children: [node('blocked-1'), node('a')],
});
const items = [section, node('b'), node('blocked-2')];
const result = sinkRestrictedNodesLast(items, isRestricted);
expect(result.map((i) => i.key)).toEqual(['section', 'b', 'blocked-2']);
expect((result[0] as SectionCreateElement).children.map((c) => c.key)).toEqual([
'a',
'blocked-1',
]);
});
});
});

View File

@ -42,10 +42,13 @@ import type { NodeViewItemSection } from './views/viewsData';
import { stripToolSuffix, useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { toPolicyNodeType } from '@/app/utils/credentialOnlyNodes';
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { NodeIconSource } from '@/app/utils/nodeIcon';
import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgentsExperiment';
import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples';
import type { NodeTypeAvailability } from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import type { INodeOutputConfiguration, NodeConnectionType } from 'n8n-workflow';
import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
@ -256,6 +259,52 @@ export function searchNodes(
export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] {
return items.map((item) => (item.type === 'section' ? item.children : item)).flat();
}
/** Restriction lookups for node creator items, with credential-only nodes following HTTP Request. */
export function getNodeItemRestriction(nodeTypeName: string): NodeTypeAvailability | null {
return getNodeTypeRestriction(toPolicyNodeType(nodeTypeName));
}
export function isNodeItemRestricted(nodeTypeName: string): boolean {
return getNodeItemRestriction(nodeTypeName) !== null;
}
type IsRestricted = (nodeTypeName: string) => boolean;
/** Browse lists: drop restricted nodes and any section that ends up empty. */
export function withoutRestrictedNodes(
items: INodeCreateElement[],
isRestricted: IsRestricted,
): INodeCreateElement[] {
return items.flatMap((item): INodeCreateElement[] => {
if (item.type === 'node' && isRestricted(item.key)) return [];
if (item.type === 'section') {
const children = withoutRestrictedNodes(item.children, isRestricted);
return children.length > 0 ? [{ ...item, children }] : [];
}
return [item];
});
}
/**
* Search lists: keep restricted nodes findable, but after every usable match. Both partitions
* keep their rank order. Sections stay in place and sink their own children.
*/
export function sinkRestrictedNodesLast(
items: INodeCreateElement[],
isRestricted: IsRestricted,
): INodeCreateElement[] {
const isRestrictedNode = (item: INodeCreateElement) =>
item.type === 'node' && isRestricted(item.key);
const available = items
.filter((item) => !isRestrictedNode(item))
.map((item) =>
item.type === 'section'
? { ...item, children: sinkRestrictedNodesLast(item.children, isRestricted) }
: item,
);
return [...available, ...items.filter(isRestrictedNode)];
}
export function isAINode(node: INodeCreateElement) {
const isNode = node.type === 'node';
if (!isNode) return false;

View File

@ -27,8 +27,10 @@ and on a cold tree turbo builds them first.
for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The
shell reads restriction through these and never through the store directly.
`ContactInstanceAdminModal` lists the instance owners with a mail link. The shell renders it where
a restricted node needs a contact action; the module owns its copy and behaviour.
`ContactInstanceAdminModal` lists the instance owners with a mail link. `RestrictedNodePopover` is the
explanation for a row in a list: the lock trigger, the hover-opened popover and the dialog. The caller
passes the row element and whether the row is keyboard-active. The shell renders both where a
restricted node appears; the module owns their copy and behaviour.
`packages/@n8n/module-cli/frontend-module-guide.md` holds the descriptor contract, the
registration points, the import boundaries and the setup a module needs to render UI.

View File

@ -24,6 +24,7 @@
"@n8n/i18n": "workspace:*",
"@n8n/rest-api-client": "workspace:*",
"@n8n/stores": "workspace:*",
"@vueuse/core": "catalog:frontend",
"pinia": "catalog:frontend",
"vue": "catalog:frontend"
},
@ -37,6 +38,7 @@
"@n8n/vitest-config": "workspace:*",
"@pinia/testing": "^0.1.6",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"@testing-library/vue": "catalog:frontend",
"@vitejs/plugin-vue": "catalog:frontend",
"eslint": "catalog:",

View File

@ -0,0 +1,77 @@
import { createComponentRenderer } from '@n8n/frontend-test-utils';
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import RestrictedNodePopover from './RestrictedNodePopover.vue';
const DESCRIPTION =
'An administrator blocked this node. To use it in your workflows, contact an instance administrator for access.';
const renderComponent = createComponentRenderer(RestrictedNodePopover, {
props: { nodeTypeName: 'Gmail', scope: 'instance' },
global: {
stubs: {
ContactInstanceAdminModal: {
props: ['open', 'description'],
template:
'<div v-if="open" data-test-id="contact-instance-admin-modal">{{ description }}</div>',
},
},
},
});
const renderPopover = (props: Record<string, unknown>) => renderComponent({ props });
describe('RestrictedNodePopover', () => {
it('renders only the lock while the row is neither hovered nor active', () => {
renderPopover({ active: false });
expect(screen.getByTestId('node-restricted-icon')).toBeInTheDocument();
expect(screen.queryByTestId('node-restricted-popover')).not.toBeInTheDocument();
});
it('opens for the keyboard-active row and names the node and the instance scope', async () => {
renderPopover({ active: true });
expect(await screen.findByTestId('node-restricted-popover')).toBeInTheDocument();
expect(screen.getByText('Gmail')).toBeInTheDocument();
expect(screen.getByText('Restricted on this instance')).toBeInTheDocument();
expect(screen.getByText(DESCRIPTION)).toBeInTheDocument();
});
it('names the project scope', async () => {
renderPopover({ active: true, scope: 'project' });
expect(await screen.findByText('Restricted in this project')).toBeInTheDocument();
});
it('falls back to the generic title for an unknown scope', async () => {
renderPopover({ active: true, scope: undefined });
expect(await screen.findByText('This node is restricted')).toBeInTheDocument();
});
it('opens when the anchor row is hovered and stays open while the pointer is on the popover', async () => {
const anchor = document.createElement('div');
document.body.appendChild(anchor);
renderPopover({ anchor });
await userEvent.hover(anchor);
const popover = await screen.findByTestId('node-restricted-popover');
await userEvent.hover(popover);
await userEvent.unhover(anchor);
expect(screen.getByTestId('node-restricted-popover')).toBeInTheDocument();
anchor.remove();
});
it('opens the contact-admin dialog for this node type', async () => {
renderPopover({ active: true });
expect(screen.queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
await userEvent.click(await screen.findByTestId('node-restricted-contact-admin'));
expect(screen.getByTestId('contact-instance-admin-modal')).toHaveTextContent('Gmail');
});
});

View File

@ -0,0 +1,125 @@
<script setup lang="ts">
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
import { N8nButton, N8nIcon, N8nPopover, N8nText } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { unrefElement, useElementHover, type MaybeElement } from '@vueuse/core';
import { computed, ref } from 'vue';
import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
const props = defineProps<{
nodeTypeName: string;
scope?: NodeTypeAvailabilityScope;
/** The list row the popover explains. It opens beside this element, not beside the lock. */
anchor?: MaybeElement;
/** The row is the keyboard-active item, which opens the popover like a hover does. */
active?: boolean;
}>();
/** Leaving waits this long before closing, so the pointer can cross the gap to the popover. */
const HOVER_GRACE_MS = 200;
const SCOPE_TITLE_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
instance: 'typeAvailabilityPolicies.restrictedNode.scope.instance',
project: 'typeAvailabilityPolicies.restrictedNode.scope.project',
};
const i18n = useI18n();
const anchorElement = computed(() => unrefElement(props.anchor) ?? undefined);
const contentRef = ref<HTMLElement | null>(null);
const anchorHovered = useElementHover(anchorElement, { delayLeave: HOVER_GRACE_MS });
const contentHovered = useElementHover(contentRef, { delayLeave: HOVER_GRACE_MS });
const open = computed(() => anchorHovered.value || contentHovered.value || props.active);
const isContactAdminOpen = ref(false);
const scopeKey = computed<BaseTextKey>(
() =>
(props.scope && SCOPE_TITLE_KEY[props.scope]) ??
'typeAvailabilityPolicies.restrictedNode.title',
);
const interpolate = computed(() => ({ nodeType: props.nodeTypeName }));
</script>
<template>
<span :class="$style.root">
<N8nPopover
:open="open"
side="left"
align="center"
:side-offset="24"
:reference="anchorElement"
:suppress-auto-focus="true"
:content-class="$style.card"
width="254px"
>
<template #trigger>
<N8nIcon
icon="lock"
size="small"
:title="i18n.baseText('typeAvailabilityPolicies.restrictedNode.title')"
data-test-id="node-restricted-icon"
/>
</template>
<template #content>
<div ref="contentRef" :class="$style.popover" data-test-id="node-restricted-popover">
<N8nText tag="p" size="large" color="text-dark">{{ nodeTypeName }}</N8nText>
<N8nText tag="p" size="small" color="text-light">{{ i18n.baseText(scopeKey) }}</N8nText>
<N8nText tag="p" size="small" color="text-base" :class="$style.description">
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
</N8nText>
<N8nButton
variant="outline"
size="small"
:class="$style.action"
data-test-id="node-restricted-contact-admin"
@click="isContactAdminOpen = true"
>
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
<N8nIcon icon="arrow-up-right" size="xsmall" />
</N8nButton>
</div>
</template>
</N8nPopover>
<ContactInstanceAdminModal
v-model:open="isContactAdminOpen"
:description="
i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.description', {
interpolate,
})
"
:mail-subject="
i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin.mailSubject', {
interpolate,
})
"
/>
</span>
</template>
<style lang="scss" module>
// Two teleported children and one visible trigger: the wrapper must not affect the slot's layout.
.root {
display: contents;
}
// The design system defaults popovers to --radius--xs (8px); the design uses the editor's 4px.
.card {
border-radius: var(--radius);
}
.popover {
padding: var(--spacing--xs);
text-align: left;
}
.description {
margin-top: var(--spacing--3xs);
}
.action {
margin-top: var(--spacing--sm);
}
</style>

View File

@ -6,3 +6,4 @@ export {
useNodeTypeRestriction,
} from './composables/useNodeTypeRestriction';
export { default as ContactInstanceAdminModal } from './components/ContactInstanceAdminModal.vue';
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';

View File

@ -6864,6 +6864,9 @@ importers:
'@n8n/stores':
specifier: workspace:*
version: link:../../../frontend/@n8n/stores
'@vueuse/core':
specifier: catalog:frontend
version: 14.3.0(vue@3.5.26(typescript@6.0.2))
pinia:
specifier: catalog:frontend
version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))
@ -6898,6 +6901,9 @@ importers:
'@testing-library/jest-dom':
specifier: catalog:frontend
version: 6.9.1
'@testing-library/user-event':
specifier: catalog:frontend
version: 14.6.1(@testing-library/dom@10.4.1)
'@testing-library/vue':
specifier: catalog:frontend
version: 8.1.0(@vue/compiler-sfc@3.5.26)(vue@3.5.26(typescript@6.0.2))