fix: Admin should be able to install community nodes (#26296)

Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
This commit is contained in:
Dimitri Lavrenük 2026-02-27 23:52:12 +01:00 committed by GitHub
parent 7af85fc297
commit e01ce10f20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 104 additions and 41 deletions

View File

@ -32,7 +32,7 @@ const usersStore = useUsersStore();
const nodeTypesStore = useNodeTypesStore();
const { installNode, loading } = useInstallNode();
const isOwner = computed(() => usersStore.isInstanceOwner);
const isAdminOrOwner = computed(() => usersStore.isAdminOrOwner);
// Fetched data from API (like CommunityNodeInfo does)
const publisherName = ref<string | undefined>(undefined);
@ -134,7 +134,7 @@ const nodeTypeForIcon = computed((): SimplifiedNodeType | null => {
});
const handleInstall = async () => {
if (!props.appEntry?.packageName || !isOwner.value) return;
if (!props.appEntry?.packageName || !isAdminOrOwner.value) return;
const result = await installNode({
type: 'verified',
@ -263,14 +263,14 @@ watch(
</a>
</div>
<ContactAdministratorToInstall v-if="!isOwner" />
<ContactAdministratorToInstall v-if="!isAdminOrOwner" />
</div>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton
v-if="isOwner"
v-if="isAdminOrOwner"
:label="i18n.baseText('communityNodeDetails.install')"
icon="download"
:loading="loading"

View File

@ -55,8 +55,12 @@ describe('NodeSettingsInvalidNodeWarning', () => {
});
describe('Owner permissions', () => {
it('should show install button when user is owner', async () => {
mockUseUsersStore.isInstanceOwner = true;
it.each([
{ isAdmin: true, isInstanceOwner: false, label: 'admin' },
{ isAdmin: false, isInstanceOwner: true, label: 'instance owner' },
])('should show install button when user is $label', ({ isAdmin, isInstanceOwner }) => {
mockUseUsersStore.isAdmin = isAdmin;
mockUseUsersStore.isInstanceOwner = isInstanceOwner;
const node = mockNode({ name: 'Test Node', type: 'n8n-nodes-test.testNode' });
const { getByTestId } = renderComponent(NodeSettingsInvalidNodeWarning, {
props: {
@ -68,7 +72,8 @@ describe('NodeSettingsInvalidNodeWarning', () => {
expect(getByTestId('install-community-node-button')).toBeInTheDocument();
});
it('should show ContactAdministratorToInstall when user is not owner', async () => {
it('should show ContactAdministratorToInstall when user is not owner or admin', async () => {
mockUseUsersStore.isAdmin = false;
mockUseUsersStore.isInstanceOwner = false;
const node = mockNode({ name: 'Test Node', type: 'n8n-nodes-test.testNode' });
const { getByText } = renderComponent(NodeSettingsInvalidNodeWarning, {
@ -86,7 +91,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
describe('View Details button', () => {
it('should open node creator when node is verified community node', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: true,
@ -109,7 +114,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
});
it('should open NPM page when node is not verified community node', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: false,
@ -135,7 +140,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
describe('Install button logic', () => {
it('should call installNode directly for verified community node', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: true,
@ -165,7 +170,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
});
it('should call installNode without preview token directly for verified community node', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: true,
@ -195,7 +200,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
});
it('should open modal for non-verified community node', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: false,
@ -228,7 +233,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
describe('Node installation watcher', () => {
it('should call unsetActiveNodeName when node is defined', async () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: true,
@ -260,7 +265,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
});
it('should not call unsetActiveNodeName when node is not defined', () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
mockUseNodeTypesStore.communityNodeType = () =>
({
isOfficialNode: true,
@ -282,7 +287,7 @@ describe('NodeSettingsInvalidNodeWarning', () => {
describe('Non-community nodes', () => {
it('should show custom node documentation link for non-community nodes', () => {
mockUseUsersStore.isInstanceOwner = true;
mockUseUsersStore.isAdmin = true;
const node = mockNode({ name: 'Custom Node', type: 'custom-node' });
const { getByText } = renderComponent(NodeSettingsInvalidNodeWarning, {

View File

@ -35,7 +35,7 @@ const isVerifiedCommunityNode = computed(
nodeTypesStore.communityNodeType(node.type)?.isOfficialNode,
);
const npmPackage = computed(() => removePreviewToken(node.type.split('.')[0]));
const isOwner = computed(() => usersStore.isInstanceOwner);
const isAdminOrOwner = computed(() => usersStore.isAdminOrOwner);
const { getQuickConnectOptionByPackageName } = useQuickConnect();
const quickConnect = computed(() => getQuickConnectOptionByPackageName(npmPackage.value));
@ -114,10 +114,10 @@ watch(isNodeDefined, () => {
<N8nText size="medium" bold>{{ npmPackage }}</N8nText>
</template>
</I18nT>
<div v-if="isOwner" :class="$style.communityNodeActionsContainer">
<div v-if="isAdminOrOwner" :class="$style.communityNodeActionsContainer">
<N8nButton
variant="solid"
v-if="isOwner"
v-if="isAdminOrOwner"
icon="hard-drive-download"
data-test-id="install-community-node-button"
:loading="loading"

View File

@ -37,7 +37,7 @@ const showError = vi.fn();
const removeNodeFromMergedNodes = vi.fn();
const usersStore = {
isInstanceOwner: true,
isAdminOrOwner: true,
};
vi.mock('@/features/credentials/credentials.store', () => ({
@ -211,7 +211,7 @@ describe('CommunityNodeDetails', () => {
});
it('should not render install button if not instance owner', async () => {
usersStore.isInstanceOwner = false;
usersStore.isAdminOrOwner = false;
const wrapper = renderComponent({ pinia });

View File

@ -34,7 +34,7 @@ const quickConnect = computed(() => {
const nodeCreatorStore = useNodeCreatorStore();
const { installNode, loading } = useInstallNode();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const isAdminOrOwner = computed(() => useUsersStore().isAdminOrOwner);
const updateViewStack = (key: string) => {
const installedNodeKey = removePreviewToken(key);
@ -71,7 +71,11 @@ const updateStoresAndViewStack = (key: string) => {
};
const onInstall = async () => {
if (isOwner.value && activeViewStack.communityNodeDetails && !communityNodeDetails?.installed) {
if (
isAdminOrOwner.value &&
activeViewStack.communityNodeDetails &&
!communityNodeDetails?.installed
) {
const { key, packageName } = activeViewStack.communityNodeDetails;
const result = await installNode({
type: 'verified',
@ -123,7 +127,7 @@ const onInstall = async () => {
</div>
<N8nButton
v-if="isOwner && !communityNodeDetails.installed"
v-if="isAdminOrOwner && !communityNodeDetails.installed"
:loading="loading"
:disabled="loading"
:label="i18n.baseText('communityNodeDetails.install')"

View File

@ -40,7 +40,9 @@ vi.mock('@/app/stores/nodeTypes.store', () => ({
vi.mock('@/features/settings/users/users.store', () => ({
useUsersStore: vi.fn(() => ({
isInstanceOwner: true,
isAdmin: true,
isAdminOrOwner: true,
isInstanceOwner: false,
})),
}));

View File

@ -37,7 +37,8 @@ const quickConnect = computed(() => {
const nodeTypesStore = useNodeTypesStore();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const usersStore = useUsersStore();
const isAdminOrOwner = computed(() => usersStore.isAdminOrOwner);
const formatNumber = (number: number) => {
if (!number) return null;
@ -175,7 +176,7 @@ onMounted(async () => {
</div>
<QuickConnectBanner v-if="quickConnect" :text="quickConnect?.text" />
<ContactAdministratorToInstall v-if="!isOwner && !communityNodeDetails?.installed" />
<ContactAdministratorToInstall v-if="!isAdminOrOwner && !communityNodeDetails?.installed" />
</div>
</template>

View File

@ -7,13 +7,13 @@ export interface Props {
hint: string;
}
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const isAdminOrOwner = computed(() => useUsersStore().isAdminOrOwner);
defineProps<Props>();
</script>
<template>
<div v-if="isOwner" :class="$style.container">
<div v-if="isAdminOrOwner" :class="$style.container">
<N8nIcon color="text-light" icon="info" size="large" />
<N8nText color="text-base" size="medium"> {{ hint }} </N8nText>
</div>

View File

@ -86,7 +86,15 @@ beforeEach(() => {
vi.mocked(useToast).mockReturnValue(toast);
Object.defineProperty(usersStore, 'isAdmin', {
value: true,
writable: true,
});
Object.defineProperty(usersStore, 'isInstanceOwner', {
value: false,
writable: true,
});
Object.defineProperty(usersStore, 'isAdminOrOwner', {
value: true,
writable: true,
});
@ -132,11 +140,19 @@ beforeEach(() => {
describe('useInstallNode', () => {
describe('installNode', () => {
it('should return error when user is not an owner', async () => {
it('should return error when user is not an owner or admin', async () => {
Object.defineProperty(usersStore, 'isAdmin', {
value: false,
writable: true,
});
Object.defineProperty(usersStore, 'isInstanceOwner', {
value: false,
writable: true,
});
Object.defineProperty(usersStore, 'isAdminOrOwner', {
value: false,
writable: true,
});
const { installNode } = useInstallNode();
const result = await installNode({
@ -147,13 +163,45 @@ describe('useInstallNode', () => {
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('User is not an owner');
expect(result.error?.message).toBe('User is not an owner or admin');
expect(showError).toHaveBeenCalledWith(
expect.any(Error),
'settings.communityNodes.messages.install.error',
);
});
it.each([
{ isAdmin: true, isInstanceOwner: false, label: 'admin' },
{ isAdmin: false, isInstanceOwner: true, label: 'instance owner' },
])('should allow installing when user is $label', async ({ isAdmin, isInstanceOwner }) => {
Object.defineProperty(usersStore, 'isAdmin', {
value: isAdmin,
writable: true,
});
Object.defineProperty(usersStore, 'isInstanceOwner', {
value: isInstanceOwner,
writable: true,
});
Object.defineProperty(usersStore, 'isAdminOrOwner', {
value: isAdmin || isInstanceOwner,
writable: true,
});
const { installNode } = useInstallNode();
const result = await installNode({
type: 'verified',
packageName: 'test-package',
nodeType: 'test-node',
});
expect(result.success).toBe(true);
expect(communityNodesStore.installPackage).toHaveBeenCalledWith(
'test-package',
true,
'1.0.0',
);
});
it('should install verified node with npm version', async () => {
const { installNode } = useInstallNode();

View File

@ -2,7 +2,7 @@ import { useCommunityNodesStore } from '../communityNodes.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { computed, nextTick, ref } from 'vue';
import { nextTick, ref } from 'vue';
import { i18n } from '@n8n/i18n';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -39,7 +39,7 @@ export function useInstallNode() {
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const workflowsStore = useWorkflowsStore();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
const userStore = useUsersStore();
const loading = ref(false);
const toast = useToast();
const canvasOperations = useCanvasOperations();
@ -56,8 +56,8 @@ export function useInstallNode() {
};
const installNode = async (props: InstallNodeProps): Promise<InstallNodeResult> => {
if (!isOwner.value) {
const error = new Error('User is not an owner');
if (!userStore.isAdminOrOwner) {
const error = new Error('User is not an owner or admin');
toast.showError(error, i18n.baseText('settings.communityNodes.messages.install.error'));
return { success: false, error };
}

View File

@ -74,7 +74,7 @@ describe('useInstalledCommunityPackage', () => {
it('should compute isUpdateCheckAvailable correctly when user is instance owner and node is community', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = true;
usersStore.isAdminOrOwner = true;
mockIsCommunityPackageName.mockReturnValue(true);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
@ -86,7 +86,7 @@ describe('useInstalledCommunityPackage', () => {
it('should compute isUpdateCheckAvailable as false when user is not instance owner', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = false;
usersStore.isAdminOrOwner = false;
mockIsCommunityPackageName.mockReturnValue(true);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
@ -98,7 +98,7 @@ describe('useInstalledCommunityPackage', () => {
it('should compute isUpdateCheckAvailable as false when node is not community', () => {
const usersStore = mockedStore(useUsersStore);
usersStore.isInstanceOwner = true;
usersStore.isAdminOrOwner = true;
mockIsCommunityPackageName.mockReturnValue(false);
const { isUpdateCheckAvailable } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');

View File

@ -51,7 +51,7 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
const isUpdateCheckAvailable = computed(() => {
return (
isCommunityNode.value &&
usersStore.isInstanceOwner &&
usersStore.isAdminOrOwner &&
!installedPackage.value?.unverifiedUpdate
);
});

View File

@ -73,6 +73,8 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
const isAdmin = computed(() => _isAdmin(currentUser.value));
const isAdminOrOwner = computed(() => isInstanceOwner.value || isAdmin.value);
const mfaEnabled = computed(() => currentUser.value?.mfaEnabled ?? false);
const globalRoleName = computed(() => currentUser.value?.role ?? 'default');
@ -459,6 +461,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
isDefaultUser,
isInstanceOwner,
isAdmin,
isAdminOrOwner,
mfaEnabled,
globalRoleName,
personalizedNodeTypes,

View File

@ -44,7 +44,7 @@ const emit = defineEmits<{
const telemetry = useTelemetry();
const i18n = useI18n();
const { userActivated, isInstanceOwner } = useUsersStore();
const usersStore = useUsersStore();
const { popViewStack, updateCurrentViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation();
const {
@ -327,7 +327,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
@selected="onSelected"
>
<N8nCallout
v-if="!userActivated && isTriggerRootView"
v-if="!usersStore.userActivated && isTriggerRootView"
theme="info"
iconless
slim
@ -371,7 +371,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
v-if="communityNodeDetails"
:class="$style.communityNodeFooter"
:package-name="communityNodeDetails.packageName"
:show-manage="communityNodeDetails.installed && isInstanceOwner"
:show-manage="communityNodeDetails.installed && usersStore.isAdminOrOwner"
/>
</div>
</template>