diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts index 34b9324978e..26dacac221c 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts @@ -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 { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts index 1cdafcb8ef2..75ef416eef7 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.test.ts @@ -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(); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue index 5f8de0d4ad5..f3764eb6ee3 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue @@ -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); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue index 3004a68c69d..a5ecb055494 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue @@ -31,7 +31,8 @@ const handleMouseLeave = () => {
{ &.selected { background-color: var(--command-bar-item--color--background--hover); } + + &.disabled { + cursor: not-allowed; + + .icon, + .content { + opacity: 0.45; + } + } } .icon { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts index dc352f79894..8dc24d4c65d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts @@ -11,4 +11,5 @@ export interface CommandBarItem { placeholder?: string; hasMoreChildren?: boolean; matchAnySearchTerm?: boolean; + disabled?: boolean; } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts index 547827f1b44..ab3ba7d93ea 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.stories.ts @@ -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 }) => ({ + `, }); @@ -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.', + }, + }, +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.test.ts index 162161f2af1..112f11c9dc9 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.test.ts @@ -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: 'lock' }, + 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: 'lock' }, + global: { stubs: ['N8nIcon'] }, + }); + + expect(container.querySelector('button')).toBeInTheDocument(); + expect(queryByText('lock')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue index e9bdc966783..7ebc2ae7af0 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nNodeCreatorNode/NodeCreatorNode.vue @@ -19,6 +19,7 @@ export interface Props { isOfficial?: boolean; hideNodeIcon?: boolean; isNew?: boolean; + disabled?: boolean; } defineProps(); @@ -27,7 +28,7 @@ defineEmits<{ tooltipClick: [e: MouseEvent]; }>(); -defineSlots<{ icon: {}; extraDetails: {}; dragContent: {} }>(); +defineSlots<{ icon: {}; extraDetails: {}; dragContent: {}; trailing: {} }>(); const { t } = useI18n(); @@ -37,13 +38,14 @@ const { t } = useI18n(); :class="{ [$style.creatorNode]: true, [$style.hasAction]: !showActionArrow, + [$style.disabled]: disabled, }" v-bind="$attrs" >
-
+
@@ -85,6 +87,9 @@ const { t } = useI18n(); +
+ +
@@ -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 @@