@@ -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);
}
diff --git a/packages/frontend/@n8n/design-system/src/constants.ts b/packages/frontend/@n8n/design-system/src/constants.ts
index 91e8d430683..ce5ae578042 100644
--- a/packages/frontend/@n8n/design-system/src/constants.ts
+++ b/packages/frontend/@n8n/design-system/src/constants.ts
@@ -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]';
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index a70037d2784..fcc3727ae2a 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -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",
diff --git a/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts b/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts
index 748b255a35b..0422c141901 100644
--- a/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts
+++ b/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts
@@ -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];
}
diff --git a/packages/frontend/editor-ui/src/features/shared/commandBar/components/CommandBarItemTitle.vue b/packages/frontend/editor-ui/src/features/shared/commandBar/components/CommandBarItemTitle.vue
index 77d623f6681..02e6d89485b 100644
--- a/packages/frontend/editor-ui/src/features/shared/commandBar/components/CommandBarItemTitle.vue
+++ b/packages/frontend/editor-ui/src/features/shared/commandBar/components/CommandBarItemTitle.vue
@@ -1,6 +1,6 @@
@@ -25,18 +40,23 @@ const i18n = useI18n();
})
}}
-
+
{{ i18n.baseText('nodeCreator.noResults.connectUsingSuggestedNode') }}
-
+
{{ i18n.baseText('nodeCreator.noResults.webhook') }}
- {{ `${i18n.baseText('nodeCreator.noResults.or')} ` }}
+ {{
+ ` ${i18n.baseText('nodeCreator.noResults.or')} `
+ }}
-
+
{{ i18n.baseText('nodeCreator.noResults.httpRequest') }}
{{ i18n.baseText('nodeCreator.noResults.node') }}
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Panel/NodesListPanel.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Panel/NodesListPanel.test.ts
index ad6be1d6ac5..f5b0bb7be49 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Panel/NodesListPanel.test.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Panel/NodesListPanel.test.ts
@@ -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();
+ }
+ });
+ });
});
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Renderers/ItemsRenderer.vue b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Renderers/ItemsRenderer.vue
index d94837d8162..f82991230c1 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Renderers/ItemsRenderer.vue
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/Renderers/ItemsRenderer.vue
@@ -193,7 +193,7 @@ watch(
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.test.ts
index c2b9ec49a3f..002af6b2a6f 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.test.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.test.ts
@@ -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();
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.ts
index 8b63d1250eb..67a7a770089 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useActions.ts
@@ -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: [] };
}
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useViewStacks.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useViewStacks.ts
index 5e0dd010b56..3a62368ff63 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useViewStacks.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/composables/useViewStacks.ts
@@ -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(() => {
@@ -265,7 +276,7 @@ export const useViewStacks = defineStore('nodeCreatorViewStacks', () => {
return true;
});
- return filteredSections;
+ return sinkRestrictedNodesLast(filteredSections, isNodeItemRestricted);
});
const itemsBySubcategory = computed(() => subcategorizeItems(nodeCreatorStore.mergedNodes));
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts
index 792c4707741..1a75a3e5c98 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts
@@ -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',
+ ]);
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts
index 2f8b100dd1a..605213e7fa8 100644
--- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts
+++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts
@@ -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;
diff --git a/packages/modules/type-availability-policies/frontend/README.md b/packages/modules/type-availability-policies/frontend/README.md
index c788567a0e6..1ec72966879 100644
--- a/packages/modules/type-availability-policies/frontend/README.md
+++ b/packages/modules/type-availability-policies/frontend/README.md
@@ -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.
diff --git a/packages/modules/type-availability-policies/frontend/package.json b/packages/modules/type-availability-policies/frontend/package.json
index fb72942c63f..c014ef661f1 100644
--- a/packages/modules/type-availability-policies/frontend/package.json
+++ b/packages/modules/type-availability-policies/frontend/package.json
@@ -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:",
diff --git a/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.test.ts b/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.test.ts
new file mode 100644
index 00000000000..085b465ffd0
--- /dev/null
+++ b/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.test.ts
@@ -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:
+ '{{ description }}
',
+ },
+ },
+ },
+});
+
+const renderPopover = (props: Record) => 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');
+ });
+});
diff --git a/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.vue b/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.vue
new file mode 100644
index 00000000000..fe80bb35387
--- /dev/null
+++ b/packages/modules/type-availability-policies/frontend/src/components/RestrictedNodePopover.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+ {{ nodeTypeName }}
+ {{ i18n.baseText(scopeKey) }}
+
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.popover.description') }}
+
+
+ {{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/modules/type-availability-policies/frontend/src/index.ts b/packages/modules/type-availability-policies/frontend/src/index.ts
index 2de063d1036..254fb488270 100644
--- a/packages/modules/type-availability-policies/frontend/src/index.ts
+++ b/packages/modules/type-availability-policies/frontend/src/index.ts
@@ -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';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f749ba02696..5a5acbcf884 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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))