mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
fix: Admin should be able to install community nodes (#26296)
Co-authored-by: RomanDavydchuk <roman.davydchuk@n8n.io>
This commit is contained in:
parent
7af85fc297
commit
e01ce10f20
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
|
|||
const isUpdateCheckAvailable = computed(() => {
|
||||
return (
|
||||
isCommunityNode.value &&
|
||||
usersStore.isInstanceOwner &&
|
||||
usersStore.isAdminOrOwner &&
|
||||
!installedPackage.value?.unverifiedUpdate
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user