feat(editor): Mark community packages with blocked node types (no-changelog) (#41105)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
Rúben Castro 2026-10-16 15:43:39 +00:00 committed by GitHub
parent 21221adc78
commit 6f3b397a1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 154 additions and 2 deletions

View File

@ -3558,6 +3558,8 @@
"settings.communityNodes.uninstallAction.label": "Uninstall package",
"settings.communityNodes.upToDate.tooltip": "You are up to date",
"settings.communityNodes.failedToLoad.tooltip": "This package failed to load. It may be corrupted, or it may need a newer version of n8n. Try uninstalling and reinstalling it, or upgrade n8n.",
"settings.communityNodes.restricted.badge.label": "Restricted",
"settings.communityNodes.restricted.tooltip.heading": "Blocked by an instance policy",
"settings.communityNodes.managedByEnv": "Community packages are managed via environment variables and cannot be modified from the UI.",
"settings.communityNodes.fetchError.title": "Problem fetching installed packages",
"settings.communityNodes.fetchError.message": "There may be a problem with your internet connection or your n8n instance",

View File

@ -5,6 +5,9 @@ import { STORES } from '@n8n/stores';
import CommunityPackageCard from './CommunityPackageCard.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import type { NodeTypeAvailability } from '@n8n/api-types';
import { waitFor } from '@testing-library/vue';
const communityPackage: PublicInstalledPackage = {
packageName: 'n8n-nodes-test',
@ -113,4 +116,72 @@ describe('CommunityPackageCard', () => {
expect(queryByText('Uninstall package')).not.toBeInTheDocument();
});
});
describe('restricted badge', () => {
const mockNodeAvailability = (
unavailable: Record<string, Pick<NodeTypeAvailability, 'scope' | 'optInAvailable'>>,
) => {
vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation(
(name: string) =>
name in unavailable
? { name, available: false, ...unavailable[name] }
: { name, available: true },
);
};
// Reka UI only mounts tooltip content once the trigger is hovered.
function hoverTrigger(element: Element) {
element.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerType: 'mouse',
clientX: 100,
clientY: 100,
}),
);
}
it('shows the badge and lists instance-blocked node types in the tooltip', async () => {
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'instance' } });
const { getByText } = renderComponent({ props: { communityPackage } });
const badge = getByText('Restricted');
expect(badge).toBeInTheDocument();
hoverTrigger(badge);
await waitFor(() => {
expect(document.querySelector('[data-dismissable-layer]')).toHaveTextContent('OldNode');
});
expect(document.querySelector('[data-dismissable-layer]')).not.toHaveTextContent('TestNode');
});
it('does not show the badge when no node type is blocked', () => {
mockNodeAvailability({});
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
it('ignores a project-scoped restriction', () => {
mockNodeAvailability({ 'n8n-nodes-test.oldNode': { scope: 'project' } });
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
it('ignores an instance restriction that projects can opt into', () => {
mockNodeAvailability({
'n8n-nodes-test.oldNode': { scope: 'instance', optInAvailable: true },
});
const { queryByText } = renderComponent({ props: { communityPackage } });
expect(queryByText('Restricted')).not.toBeInTheDocument();
});
});
});

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { useUIStore } from '@/app/stores/ui.store';
import type { IUser, PublicInstalledPackage } from 'n8n-workflow';
import type { IUser, PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
import { COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '../communityNodes.constants';
import { NPM_PACKAGE_DOCS_BASE_URL } from '@/app/constants';
import { useI18n } from '@n8n/i18n';
@ -8,6 +8,7 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { UserAction } from '@n8n/design-system';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies';
import { computed, ref, watch } from 'vue';
import {
findVettedCommunityNodeAttributes,
@ -16,6 +17,7 @@ import {
import {
N8nActionToggle,
N8nBadge,
N8nButton,
N8nIcon,
N8nLoading,
@ -57,6 +59,17 @@ const hasUpdateAvailable = computed(() => {
});
});
// Packages are installed instance-wide, so only a hard instance block counts. A delegated
// type (`optInAvailable`) is not blocked, because any project can allow it.
const blockedNodes = computed<PublicInstalledNode[]>(() => {
if (!props.communityPackage) return [];
return props.communityPackage.installedNodes.filter((node) => {
const restriction = getNodeTypeRestriction(node.type);
return restriction?.scope === 'instance' && !restriction.optInAvailable;
});
});
const packageActions = computed<Array<UserAction<IUser>>>(() => {
const actions: Array<UserAction<IUser>> = [
{
@ -120,6 +133,25 @@ watch(
<div :class="$style.cardInfoContainer">
<div :class="$style.cardTitle">
<N8nText :bold="true" size="large">{{ communityPackage.packageName }}</N8nText>
<N8nTooltip v-if="blockedNodes.length > 0" placement="top">
<template #content>
<div>
<N8nText tag="p" size="small" :bold="true">
{{ i18n.baseText('settings.communityNodes.restricted.tooltip.heading') }}
</N8nText>
<ul :class="$style.blockedNodesList">
<li v-for="node in blockedNodes" :key="node.type">{{ node.name }}</li>
</ul>
</div>
</template>
<N8nBadge
variant="filled"
leading-icon="lock"
data-test-id="community-package-restricted-badge"
>
{{ i18n.baseText('settings.communityNodes.restricted.badge.label') }}
</N8nBadge>
</N8nTooltip>
</div>
<div :class="$style.cardSubtitle">
<N8nText :bold="true" size="small" color="text-light">
@ -216,6 +248,9 @@ watch(
}
.cardTitle {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
flex-basis: 100%;
span {
@ -223,6 +258,11 @@ watch(
}
}
.blockedNodesList {
margin: var(--spacing--4xs) 0 0;
padding-left: var(--spacing--sm);
}
.cardSubtitle {
margin-top: 2px;
padding-right: var(--spacing--md);

View File

@ -7,6 +7,9 @@ import { setActivePinia } from 'pinia';
import { STORES } from '@n8n/stores';
import { useCommunityNodesStore } from '../communityNodes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { mockedStore } from '@/__tests__/utils';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import { COMMUNITY_PACKAGE_INSTALL_MODAL_KEY } from '../communityNodes.constants';
import type { PublicInstalledPackage, PublicInstalledNode } from 'n8n-workflow';
@ -111,6 +114,7 @@ const setup = (
availablePackageCount?: number;
communityNodesManagedByEnv?: boolean;
} = {},
{ beforeRender }: { beforeRender?: () => void } = {},
) => {
const {
packages = [],
@ -140,6 +144,8 @@ const setup = (
uiStore.openModal = vi.fn();
beforeRender?.();
return {
...renderComponent({ pinia }),
communityNodesStore,
@ -282,4 +288,23 @@ describe('SettingsCommunityNodesView', () => {
expect(uiStore.openModal).toHaveBeenCalledWith(COMMUNITY_PACKAGE_INSTALL_MODAL_KEY);
});
});
describe('node type availability', () => {
it("loads the policy for the user's personal project", async () => {
const PERSONAL_PROJECT_ID = 'personal-project-1';
let fetchForProject: ReturnType<typeof vi.fn>;
setup(undefined, {
beforeRender: () => {
mockedStore(useProjectsStore).personalProject = { id: PERSONAL_PROJECT_ID } as never;
fetchForProject = vi
.spyOn(useTypeAvailabilityPoliciesStore(), 'fetchForProject')
.mockResolvedValue(undefined);
},
});
await flushPromises();
expect(fetchForProject!).toHaveBeenCalledWith(PERSONAL_PROJECT_ID);
});
});
});

View File

@ -10,7 +10,7 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
import { useCommunityNodesStore } from '../communityNodes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted } from 'vue';
import { onBeforeUnmount, ref, computed, onBeforeMount, onMounted, watch } from 'vue';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useRouter } from 'vue-router';
import { usePushConnection } from '@/app/composables/usePushConnection';
@ -18,6 +18,8 @@ import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
import { N8nEmptyState, N8nButton, N8nHeading, N8nNotice } from '@n8n/design-system';
const PACKAGE_COUNT_THRESHOLD = 31;
@ -36,6 +38,8 @@ const documentTitle = useDocumentTitle();
const communityNodesStore = useCommunityNodesStore();
const uiStore = useUIStore();
const settingsStore = useSettingsStore();
const projectsStore = useProjectsStore();
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
const isManagedByEnv = computed((): boolean => {
return settingsStore.settings.communityNodesManagedByEnv ?? false;
@ -154,6 +158,16 @@ onBeforeUnmount(() => {
pushStore.pushDisconnect();
pushConnection.terminate();
});
// Node type availability is project-scoped, but this page has no project context of its own.
// The personal project is only used to carry the instance-wide verdicts the cards need.
watch(
() => projectsStore.personalProject?.id,
(projectId) => {
if (projectId) void typeAvailabilityPoliciesStore.fetchForProject(projectId);
},
{ immediate: true },
);
</script>
<template>