mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Show the restriction callout and lock the agent tool configuration form (#40657)
This commit is contained in:
parent
6f5f79321b
commit
01da88958d
|
|
@ -2544,6 +2544,9 @@
|
|||
"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.restrictedNode.replaceNode": "Replace node",
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.instance": "{nodeType} is restricted on this instance. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.project": "{nodeType} is restricted in this project. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.restrictedNode.agentTool.generic": "{nodeType} is restricted. The agent can't use it.",
|
||||
"typeAvailabilityPolicies.contactAdmin.title": "Instance admins",
|
||||
"typeAvailabilityPolicies.contactAdmin.description": "Instance admins manage which nodes are available. Contact one of them to request access to '{nodeType}'.",
|
||||
"typeAvailabilityPolicies.contactAdmin.mailSubject": "Access request for the {nodeType} node",
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ import { createComponentRenderer } from '@/__tests__/render';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { defineComponent, onMounted, nextTick } from 'vue';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import { AgentModalTestStub } from './utils/AgentModalTestStub';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks';
|
||||
|
||||
import AgentToolConfigModal from '../components/AgentToolConfigModal.vue';
|
||||
import type { AgentJsonToolRef, CustomToolEntry } from '../types';
|
||||
|
|
@ -64,6 +67,7 @@ function createToolSettingsStub(emitValid: boolean) {
|
|||
'projectId',
|
||||
'parameterIssues',
|
||||
'fromAiDisabledParameters',
|
||||
'readOnly',
|
||||
],
|
||||
emits: ['update:valid', 'update:node-name', 'update:node'],
|
||||
setup(props, { emit, expose }) {
|
||||
|
|
@ -91,7 +95,11 @@ function createToolSettingsStub(emitValid: boolean) {
|
|||
return {};
|
||||
},
|
||||
template: `
|
||||
<div data-test-id="node-tool-settings-content" :data-project-id="projectId">
|
||||
<div
|
||||
data-test-id="node-tool-settings-content"
|
||||
:data-project-id="projectId"
|
||||
:data-read-only="readOnly"
|
||||
>
|
||||
<button
|
||||
v-if="!fromAiDisabledParameters?.includes('url')"
|
||||
data-test-id="from-ai-override-button"
|
||||
|
|
@ -197,15 +205,19 @@ function renderModal({
|
|||
AgentToolConfigNodeContent: createToolSettingsStub(valid),
|
||||
AgentToolConfigWorkflowContent: createWorkflowToolConfigStub(valid),
|
||||
N8nSwitch2: {
|
||||
props: ['modelValue'],
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template:
|
||||
'<button data-test-id="agent-tool-approval-toggle" :data-checked="modelValue" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
'<button data-test-id="agent-tool-approval-toggle" :data-checked="modelValue" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
},
|
||||
AgentToolConfigCustomContent: {
|
||||
props: ['code'],
|
||||
template: '<pre data-test-id="agent-custom-tool-viewer">{{ code }}</pre>',
|
||||
},
|
||||
ContactInstanceAdminModal: {
|
||||
props: ['open', 'nodeTypeName'],
|
||||
template: '<div v-if="open" data-test-id="contact-instance-admin-modal" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -232,6 +244,7 @@ describe('AgentToolConfigModal', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createTestingPinia({ stubActions: false });
|
||||
vi.spyOn(useNodeTypesStore(), 'loadNodeTypesIfNotLoaded').mockResolvedValue(undefined);
|
||||
uiStore = mockedStore(useUIStore);
|
||||
uiStore.openModal(MODAL_NAME);
|
||||
uiStore.closeModal = vi.fn();
|
||||
|
|
@ -242,6 +255,12 @@ describe('AgentToolConfigModal', () => {
|
|||
expect(getByTestId('node-tool-settings-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads the node types so the tool shows its display name and parameters', () => {
|
||||
renderModal();
|
||||
|
||||
expect(useNodeTypesStore().loadNodeTypesIfNotLoaded).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not open for a persisted node tool without node data', () => {
|
||||
const invalidRef = {
|
||||
type: 'node',
|
||||
|
|
@ -493,4 +512,69 @@ describe('AgentToolConfigModal', () => {
|
|||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
});
|
||||
|
||||
describe('restricted node types', () => {
|
||||
beforeEach(() => {
|
||||
mockRestrictedNodeTypes({ 'n8n-nodes-base.slack': 'instance' });
|
||||
});
|
||||
|
||||
it('loads the policy for the agent project', () => {
|
||||
const fetchForProject = vi
|
||||
.spyOn(useTypeAvailabilityPoliciesStore(), 'fetchForProject')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
renderModal({ projectId: 'project-1' });
|
||||
|
||||
expect(fetchForProject).toHaveBeenCalledWith('project-1');
|
||||
});
|
||||
|
||||
it('explains the restriction, locks the form and disables Save', async () => {
|
||||
const { container, getByTestId } = renderModal({ valid: true });
|
||||
await nextTick();
|
||||
|
||||
expect(getByTestId('restricted-tool-callout').textContent).toContain(
|
||||
'typeAvailabilityPolicies.restrictedNode.agentTool.instance',
|
||||
);
|
||||
expect(getByTestId('node-tool-settings-content')).toHaveAttribute('data-read-only', 'true');
|
||||
expect(getByTestId('agent-tool-approval-toggle')).toBeDisabled();
|
||||
expect(queryNativeTestId(container, 'agent-modal-title-input')).toBeNull();
|
||||
|
||||
const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the contact admin dialog from the callout', async () => {
|
||||
const { getByTestId, queryByTestId } = renderModal();
|
||||
await nextTick();
|
||||
|
||||
expect(queryByTestId('contact-instance-admin-modal')).toBeNull();
|
||||
await fireEvent.click(getByTestId('restricted-tool-contact-admin'));
|
||||
|
||||
expect(getByTestId('contact-instance-admin-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Remove so the agent can be repaired', async () => {
|
||||
const onRemove = vi.fn();
|
||||
const { container } = renderModal({ onRemove });
|
||||
await nextTick();
|
||||
|
||||
await fireEvent.click(getNativeTestId(container, 'agent-tool-config-remove'));
|
||||
|
||||
expect(onRemove).toHaveBeenCalledOnce();
|
||||
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
|
||||
});
|
||||
|
||||
it('leaves an unrestricted tool editable', async () => {
|
||||
mockRestrictedNodeTypes();
|
||||
const { container, getByTestId, queryByTestId } = renderModal({ valid: true });
|
||||
await nextTick();
|
||||
|
||||
expect(queryByTestId('restricted-tool-callout')).toBeNull();
|
||||
expect(getByTestId('node-tool-settings-content')).toHaveAttribute('data-read-only', 'false');
|
||||
expect(getByTestId('agent-tool-approval-toggle')).not.toBeDisabled();
|
||||
expect(getNativeTestId(container, 'agent-modal-title-input')).toBeInTheDocument();
|
||||
const saveBtn = getNativeTestId(container, 'agent-tool-config-save') as HTMLButtonElement;
|
||||
expect(saveBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,13 +14,14 @@ const renderComponent = createComponentRenderer(AgentToolConfigNodeContent, {
|
|||
stubs: {
|
||||
NodeToolSettingsContent: {
|
||||
template:
|
||||
'<div data-test-id="node-tool-settings" :data-refresh-empty-schema="refreshEmptySchemaEnabled" :data-schema-auto-refresh="schemaAutoRefreshEnabled" :data-sync-node-to-ndv="syncNodeToNdv">{{ JSON.stringify(hiddenOperations) }}</div>',
|
||||
'<div data-test-id="node-tool-settings" :data-refresh-empty-schema="refreshEmptySchemaEnabled" :data-schema-auto-refresh="schemaAutoRefreshEnabled" :data-sync-node-to-ndv="syncNodeToNdv" :data-read-only="readOnly">{{ JSON.stringify(hiddenOperations) }}</div>',
|
||||
props: [
|
||||
'initialNode',
|
||||
'existingToolNames',
|
||||
'projectId',
|
||||
'hiddenOperations',
|
||||
'syncNodeToNdv',
|
||||
'readOnly',
|
||||
],
|
||||
setup() {
|
||||
return {
|
||||
|
|
@ -72,4 +73,12 @@ describe('AgentToolConfigNodeContent', () => {
|
|||
|
||||
expect(getByTestId('node-tool-settings')).toHaveAttribute('data-sync-node-to-ndv', 'true');
|
||||
});
|
||||
|
||||
it('forwards the read-only state', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { initialNode: node, contentTestId: 'node-tool-settings', readOnly: true },
|
||||
});
|
||||
|
||||
expect(getByTestId('node-tool-settings')).toHaveAttribute('data-read-only', 'true');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1261,6 +1261,22 @@ describe('AgentToolsConnectionModalWrapper', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('opens the existing configuration of a connected restricted tool', async () => {
|
||||
mockRestrictedNodeTypes({ [SLACK.name]: 'instance' });
|
||||
const ref = toolRef(SLACK.name);
|
||||
|
||||
render([ref]);
|
||||
await flushPromises();
|
||||
|
||||
const connected = getItems().find((item) => item.status === 'connected');
|
||||
emitOpenDetail(connected!);
|
||||
await flushPromises();
|
||||
|
||||
const data = getConfigData();
|
||||
expect(data.kind !== 'mcpServer' && data.toolRef).toEqual(ref);
|
||||
expect(data.onRemove).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds nothing when the installed community tool turns out to be restricted', async () => {
|
||||
mockCommunityPreviewCatalog();
|
||||
mockRestrictedNodeTypes();
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const props = defineProps<{
|
|||
/** Hide the "Disabled" option — for callers that wrap the selector in a
|
||||
* toggle that already handles enable/disable. */
|
||||
hideDisabledOption?: boolean;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -143,6 +144,7 @@ function handleSelectedUpdate(value: unknown) {
|
|||
<div :class="$style.controls">
|
||||
<N8nSelect
|
||||
:model-value="approvalMode"
|
||||
:disabled="props.disabled"
|
||||
size="small"
|
||||
:data-test-id="`${props.testIdPrefix}-mode`"
|
||||
:class="$style.modeSelect"
|
||||
|
|
@ -163,6 +165,7 @@ function handleSelectedUpdate(value: unknown) {
|
|||
:model-value="selectedEntries"
|
||||
multiple
|
||||
filterable
|
||||
:disabled="props.disabled"
|
||||
size="small"
|
||||
:loading="props.loading"
|
||||
:placeholder="props.placeholder"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -25,6 +26,7 @@ const i18n = useI18n();
|
|||
</div>
|
||||
<N8nSwitch2
|
||||
:model-value="modelValue"
|
||||
:disabled="disabled"
|
||||
data-test-id="agent-tool-approval-toggle"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
import type { AgentConfigValidationIssue } from '@n8n/api-types';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import {
|
||||
RestrictedToolCallout,
|
||||
useNodeTypeRestriction,
|
||||
} from '@n8n/frontend-module-type-availability-policies';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { extractFromAICalls, type INode } from 'n8n-workflow';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
|
||||
import {
|
||||
|
|
@ -62,10 +67,12 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
'update:title': [title: string];
|
||||
'update:credentialModalOpen': [open: boolean];
|
||||
'update:restricted': [restricted: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const httpRequestUrlErrorKey =
|
||||
'agents.builder.validation.issue.httpRequestUrlFromAi' as BaseTextKey;
|
||||
|
||||
|
|
@ -113,6 +120,15 @@ const initialNode = computed<INode | null>(() =>
|
|||
: null,
|
||||
);
|
||||
|
||||
const { isRestricted, restrictionScope } = useNodeTypeRestriction(() => initialNode.value?.type);
|
||||
const restrictedToolName = computed(() =>
|
||||
initialNode.value
|
||||
? (nodeTypesStore.getNodeType(initialNode.value.type)?.displayName ?? initialNode.value.type)
|
||||
: '',
|
||||
);
|
||||
|
||||
watch(isRestricted, (restricted) => emit('update:restricted', restricted), { immediate: true });
|
||||
|
||||
const workflowInitialRef = computed<WorkflowToolRef | null>(() =>
|
||||
isWorkflowTool.value && toolModalData.value?.toolRef.type === 'workflow'
|
||||
? toolModalData.value.toolRef
|
||||
|
|
@ -299,6 +315,11 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
|
||||
<template>
|
||||
<div :class="[$style.contentWrapper, isCustomTool && $style.codeContentWrapper]">
|
||||
<RestrictedToolCallout
|
||||
v-if="isRestricted"
|
||||
:node-type-name="restrictedToolName"
|
||||
:scope="restrictionScope"
|
||||
/>
|
||||
<N8nText
|
||||
v-if="submitCount && !canSave"
|
||||
size="small"
|
||||
|
|
@ -335,6 +356,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
:initial-node="initialNode"
|
||||
:existing-tool-names="data.existingToolNames"
|
||||
:project-id="data.projectId"
|
||||
:read-only="isRestricted"
|
||||
content-test-id="agent-tool-config-mcp-content"
|
||||
@update:valid="isValid = $event"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
|
|
@ -348,6 +370,7 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
:project-id="data.projectId"
|
||||
:from-ai-disabled-parameters="fromAiDisabledParameters"
|
||||
:parameter-issues="nodeParameterIssues"
|
||||
:read-only="isRestricted"
|
||||
content-test-id="node-tool-settings-content"
|
||||
@update:valid="isValid = $event"
|
||||
@update:node-name="handleNodeNameUpdate"
|
||||
|
|
@ -356,11 +379,13 @@ defineExpose({ canSave, confirm, remove, changeTitle, credentialModalOpen, title
|
|||
<AgentToolConfigApprovalSetting
|
||||
v-if="!isMcpTool && initialNode && showApprovalSetting"
|
||||
v-model="approvalRequired"
|
||||
:disabled="isRestricted"
|
||||
/>
|
||||
<AgentToolConfigMcpApprovalSetting
|
||||
v-if="isMcpTool && currentNode && supportsApproval"
|
||||
v-model="mcpApproval"
|
||||
:node="currentNode"
|
||||
:disabled="isRestricted"
|
||||
:project-id="data.projectId"
|
||||
@update:valid="mcpApprovalValid = $event"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const props = defineProps<{
|
|||
modelValue?: AgentJsonMcpServerConfig['approval'];
|
||||
node: INode;
|
||||
projectId?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -104,6 +105,7 @@ async function refreshTools() {
|
|||
:hint="i18n.baseText('agents.toolConfig.mcpApproval.hint')"
|
||||
:placeholder="i18n.baseText('agents.toolConfig.mcpApproval.tools.placeholder')"
|
||||
:loading="isLoadingTools"
|
||||
:disabled="props.disabled"
|
||||
:error="loadingError ? i18n.baseText('agents.toolConfig.mcpApproval.loadError') : null"
|
||||
test-id-prefix="agent-mcp-approval"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
|
|
@ -119,6 +121,7 @@ async function refreshTools() {
|
|||
variant="subtle"
|
||||
size="small"
|
||||
icon-only
|
||||
:disabled="props.disabled"
|
||||
:loading="isLoadingTools"
|
||||
:aria-label="i18n.baseText('agents.toolConfig.mcpApproval.refresh')"
|
||||
data-test-id="agent-mcp-approval-refresh"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nButton, N8nIcon } from '@n8n/design-system';
|
||||
import { useTypeAvailabilityPoliciesStore } from '@n8n/frontend-module-type-availability-policies';
|
||||
import { useI18n, type BaseTextKey } from '@n8n/i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
|
||||
import AgentModal from './modals/AgentModal.vue';
|
||||
|
|
@ -17,8 +19,11 @@ const props = defineProps<{
|
|||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const typeAvailabilityPoliciesStore = useTypeAvailabilityPoliciesStore();
|
||||
const form = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
|
||||
const credentialModalOpen = ref(false);
|
||||
const isRestricted = ref(false);
|
||||
const title = ref(initialTitle());
|
||||
|
||||
const isOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
|
||||
|
|
@ -40,6 +45,16 @@ const removeLabel = computed(() => {
|
|||
return i18n.baseText('agents.builder.tools.remove');
|
||||
});
|
||||
|
||||
void nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
|
||||
watch(
|
||||
() => props.data.projectId,
|
||||
(projectId) => {
|
||||
if (projectId) void typeAvailabilityPoliciesStore.fetchForProject(projectId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function initialTitle(): string {
|
||||
if (props.data.kind === 'mcpServer') return props.data.mcpServer.name;
|
||||
if (props.data.toolRef.type === 'custom') {
|
||||
|
|
@ -80,7 +95,7 @@ function handleRemove() {
|
|||
v-if="canRender"
|
||||
:open="isOpen"
|
||||
:title="title"
|
||||
:editable-title="!isCustomTool"
|
||||
:editable-title="!isCustomTool && !isRestricted"
|
||||
:trap-focus="!credentialModalOpen"
|
||||
:disable-outside-pointer-events="!credentialModalOpen"
|
||||
data-testid="agent-tool-config-modal"
|
||||
|
|
@ -93,6 +108,7 @@ function handleRemove() {
|
|||
:data="data"
|
||||
@update:title="title = $event"
|
||||
@update:credential-modal-open="credentialModalOpen = $event"
|
||||
@update:restricted="isRestricted = $event"
|
||||
/>
|
||||
|
||||
<template v-if="data.onRemove" #footerLeft>
|
||||
|
|
@ -102,7 +118,12 @@ function handleRemove() {
|
|||
</N8nButton>
|
||||
</template>
|
||||
<template #footerActions>
|
||||
<N8nButton variant="solid" data-testid="agent-tool-config-save" @click="handleConfirm">
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="isRestricted"
|
||||
data-testid="agent-tool-config-save"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const props = defineProps<{
|
|||
contentTestId?: string;
|
||||
parameterIssues?: Record<string, string[]>;
|
||||
fromAiDisabledParameters?: string[];
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -63,6 +64,7 @@ defineExpose({
|
|||
:hidden-operations="HIDDEN_AGENT_NODE_TOOL_OPERATIONS"
|
||||
:parameter-issues="props.parameterIssues"
|
||||
:from-ai-disabled-parameters="props.fromAiDisabledParameters"
|
||||
:read-only="props.readOnly"
|
||||
:sync-node-to-ndv="true"
|
||||
:data-test-id="props.contentTestId"
|
||||
@update:valid="emit('update:valid', $event)"
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ const configForm = ref<InstanceType<typeof AgentToolConfigForm> | null>(null);
|
|||
const configTitle = ref('');
|
||||
const configSession = ref(0);
|
||||
const isCredentialModalOpen = ref(false);
|
||||
const configIsRestricted = ref(false);
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => uiStore.modalsById[props.modalName]?.open === true,
|
||||
|
|
@ -864,8 +865,9 @@ function handleRowActivate(item: ToolConnectionItem) {
|
|||
// Disabled rows (e.g. incompatible workflows) are visible-but-not-selectable;
|
||||
// the row's own tooltip already explains why, so activating does nothing.
|
||||
if (item.disabled) return;
|
||||
if (item.kind === 'node' && item.restriction) return;
|
||||
if (item.status === 'connecting') return;
|
||||
const isRestricted = item.kind === 'node' && Boolean(item.restriction);
|
||||
if (isRestricted && !hasToolConnection(item.status)) return;
|
||||
if (hasToolConnection(item.status)) {
|
||||
if (item.id.startsWith('mcp:')) {
|
||||
const localId = item.id.slice('mcp:'.length);
|
||||
|
|
@ -883,7 +885,7 @@ function handleRowActivate(item: ToolConnectionItem) {
|
|||
// n8n Connect managed tool must keep its managed-credential
|
||||
// preselection, so route it through the same managed add path.
|
||||
const { ref } = entry;
|
||||
if (ref.type === 'node') {
|
||||
if (ref.type === 'node' && !isRestricted) {
|
||||
const nodeType =
|
||||
[...availableToolTypes.value, ...communitySearchToolTypes.value].find(
|
||||
(nt) => nt.name === ref.node.nodeType,
|
||||
|
|
@ -934,7 +936,7 @@ function handleRowActivate(item: ToolConnectionItem) {
|
|||
:open="isOpen"
|
||||
:step="currentStep"
|
||||
:title="modalTitle"
|
||||
:editable-title="Boolean(configData) && !configIsCustom"
|
||||
:editable-title="Boolean(configData) && !configIsCustom && !configIsRestricted"
|
||||
:show-back="Boolean(configData)"
|
||||
:show-footer="Boolean(configData)"
|
||||
:busy="isCredentialModalOpen || isCreatingWorkflow"
|
||||
|
|
@ -995,6 +997,7 @@ function handleRowActivate(item: ToolConnectionItem) {
|
|||
:data="configData"
|
||||
@update:title="configTitle = $event"
|
||||
@update:credential-modal-open="isCredentialModalOpen = $event"
|
||||
@update:restricted="configIsRestricted = $event"
|
||||
/>
|
||||
|
||||
<template v-if="configData?.onRemove" #footerLeft>
|
||||
|
|
@ -1006,7 +1009,7 @@ function handleRowActivate(item: ToolConnectionItem) {
|
|||
<template v-if="configData" #footerActions>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="isCredentialModalOpen"
|
||||
:disabled="isCredentialModalOpen || configIsRestricted"
|
||||
data-testid="agent-tool-config-save"
|
||||
@click="saveConfig"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const props = defineProps<{
|
|||
fromAiDisabledParameters?: string[];
|
||||
/** Keeps standalone Agent tool parameters resolvable through the scoped NDV store. */
|
||||
syncNodeToNdv?: boolean;
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -484,7 +485,7 @@ defineExpose({ node, isValid, nodeTypeDescription, handleChangeName });
|
|||
:parameters="parametersByTab.params"
|
||||
:hide-delete="true"
|
||||
:node-values="node.parameters"
|
||||
:is-read-only="false"
|
||||
:is-read-only="props.readOnly"
|
||||
:node="node"
|
||||
:parameter-issues="props.parameterIssues"
|
||||
:from-ai-disabled-parameters="props.fromAiDisabledParameters"
|
||||
|
|
@ -492,7 +493,7 @@ defineExpose({ node, isValid, nodeTypeDescription, handleChangeName });
|
|||
>
|
||||
<NodeCredentials
|
||||
:node="node"
|
||||
:readonly="false"
|
||||
:readonly="props.readOnly"
|
||||
:show-all="true"
|
||||
:project-id="credentialProjectId"
|
||||
:hide-issues="false"
|
||||
|
|
@ -518,7 +519,7 @@ defineExpose({ node, isValid, nodeTypeDescription, handleChangeName });
|
|||
v-if="node && parametersByTab.settings.length > 0"
|
||||
:parameters="parametersByTab.settings"
|
||||
:node-values="settingsNodeValues"
|
||||
:is-read-only="false"
|
||||
:is-read-only="props.readOnly"
|
||||
:hide-delete="true"
|
||||
path="parameters"
|
||||
:node="node"
|
||||
|
|
@ -529,7 +530,7 @@ defineExpose({ node, isValid, nodeTypeDescription, handleChangeName });
|
|||
:parameters="nodeSettings"
|
||||
:hide-delete="true"
|
||||
:node-values="settingsNodeValues"
|
||||
:is-read-only="false"
|
||||
:is-read-only="props.readOnly"
|
||||
path=""
|
||||
:node="node"
|
||||
@value-changed="handleChangeSettingsValue"
|
||||
|
|
|
|||
|
|
@ -131,11 +131,11 @@ const renderComponent = createComponentRenderer(NodeToolSettingsContent, {
|
|||
stubs: {
|
||||
ParameterInputList: {
|
||||
template:
|
||||
'<div data-test-id="parameter-input-list">{{ JSON.stringify(parameters) }}<slot /></div>',
|
||||
'<div data-test-id="parameter-input-list" :data-read-only="isReadOnly">{{ JSON.stringify(parameters) }}<slot /></div>',
|
||||
props: ['parameters', 'nodeValues', 'isReadOnly', 'hideDelete', 'node', 'path'],
|
||||
},
|
||||
NodeCredentials: {
|
||||
template: '<div data-test-id="node-credentials" />',
|
||||
template: '<div data-test-id="node-credentials" :data-read-only="readonly" />',
|
||||
props: ['node', 'readonly', 'showAll', 'hideIssues'],
|
||||
},
|
||||
},
|
||||
|
|
@ -285,6 +285,28 @@ describe('NodeToolSettingsContent', () => {
|
|||
expect(getByTestId('node-credentials')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('locks parameters and credentials when read-only', () => {
|
||||
const { getAllByTestId, getByTestId } = renderComponent({
|
||||
props: { initialNode: createMockNode(), readOnly: true },
|
||||
});
|
||||
|
||||
for (const list of getAllByTestId('parameter-input-list')) {
|
||||
expect(list).toHaveAttribute('data-read-only', 'true');
|
||||
}
|
||||
expect(getByTestId('node-credentials')).toHaveAttribute('data-read-only', 'true');
|
||||
});
|
||||
|
||||
it('leaves parameters and credentials editable by default', () => {
|
||||
const { getAllByTestId, getByTestId } = renderComponent({
|
||||
props: { initialNode: createMockNode() },
|
||||
});
|
||||
|
||||
for (const list of getAllByTestId('parameter-input-list')) {
|
||||
expect(list).toHaveAttribute('data-read-only', 'false');
|
||||
}
|
||||
expect(getByTestId('node-credentials')).toHaveAttribute('data-read-only', 'false');
|
||||
});
|
||||
|
||||
it('should show no-parameters notice when all params are notice type', () => {
|
||||
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(MOCK_NODE_TYPE_NO_PARAMS);
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ const isRowNavigable = computed(() => props.item.kind !== 'service');
|
|||
const restriction = computed(() =>
|
||||
props.item.kind === 'node' ? props.item.restriction : undefined,
|
||||
);
|
||||
const isActivationBlocked = computed(
|
||||
() => Boolean(restriction.value) && !hasToolConnection(props.item.status),
|
||||
);
|
||||
|
||||
const rowRef = ref<HTMLElement | null>(null);
|
||||
|
||||
|
|
@ -125,7 +128,7 @@ const hasDirectAction = computed(
|
|||
|
||||
function handleRowClick() {
|
||||
if (props.item.disabled) return;
|
||||
if (restriction.value) return;
|
||||
if (isActivationBlocked.value) return;
|
||||
if (props.item.status === 'connecting') return;
|
||||
emit('open-detail', props.item);
|
||||
}
|
||||
|
|
@ -144,7 +147,11 @@ function handleConnect() {
|
|||
:class="[
|
||||
$style.row,
|
||||
$style[`row--${item.kind}`],
|
||||
{ [$style.rowDisabled]: isDisabled, [$style.rowRestricted]: !!restriction },
|
||||
{
|
||||
[$style.rowDisabled]: isDisabled,
|
||||
[$style.rowRestricted]: !!restriction,
|
||||
[$style.rowBlocked]: isActivationBlocked,
|
||||
},
|
||||
]"
|
||||
:data-test-id="`tools-connection-row`"
|
||||
:data-row-kind="item.kind"
|
||||
|
|
@ -154,7 +161,7 @@ function handleConnect() {
|
|||
:type="isRowNavigable ? 'button' : undefined"
|
||||
:class="[$style.mainAction, { [$style.mainActionStatic]: !isRowNavigable }]"
|
||||
:disabled="isRowNavigable ? isDisabled || item.status === 'connecting' : undefined"
|
||||
:aria-disabled="!!restriction || undefined"
|
||||
:aria-disabled="isActivationBlocked || undefined"
|
||||
data-test-id="tools-connection-row-main"
|
||||
@click="isRowNavigable && handleRowClick()"
|
||||
>
|
||||
|
|
@ -356,12 +363,14 @@ function handleConnect() {
|
|||
}
|
||||
|
||||
.rowRestricted {
|
||||
.mainAction > * {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.rowBlocked {
|
||||
.mainAction {
|
||||
cursor: not-allowed;
|
||||
|
||||
> * {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -372,6 +372,16 @@ describe('ToolRow', () => {
|
|||
expect(emitted().connect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opens the detail of a connected restricted row', async () => {
|
||||
const { getByTestId, emitted } = render({ ...restrictedNode, status: 'connected' });
|
||||
|
||||
const main = getByTestId('tools-connection-row-main');
|
||||
expect(main.getAttribute('aria-disabled')).toBeNull();
|
||||
await fireEvent.click(main);
|
||||
|
||||
expect(emitted()['open-detail']?.[0]).toEqual([{ ...restrictedNode, status: 'connected' }]);
|
||||
});
|
||||
|
||||
it('shows the lock, not the install action, for a restricted community node', () => {
|
||||
const item: NodeConnectionItem = {
|
||||
...restrictedNode,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import RestrictedToolCallout from './RestrictedToolCallout.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(RestrictedToolCallout, {
|
||||
props: { nodeTypeName: 'Slack' },
|
||||
global: {
|
||||
stubs: {
|
||||
ContactInstanceAdminModal: {
|
||||
props: ['open', 'nodeTypeName'],
|
||||
template:
|
||||
'<div v-if="open" data-test-id="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('RestrictedToolCallout', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
});
|
||||
|
||||
it('names the tool and the blocking scope', () => {
|
||||
const { getByTestId } = renderComponent({ props: { scope: 'instance' } });
|
||||
|
||||
expect(getByTestId('restricted-tool-callout')).toHaveTextContent(
|
||||
"Slack is restricted on this instance. The agent can't use it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a missing scope generically', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('restricted-tool-callout')).toHaveTextContent(
|
||||
"Slack is restricted. The agent can't use it.",
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the contact-admin dialog for this tool', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(queryByTestId('contact-instance-admin-modal')).not.toBeInTheDocument();
|
||||
await fireEvent.click(getByTestId('restricted-tool-contact-admin'));
|
||||
|
||||
expect(getByTestId('contact-instance-admin-modal')).toHaveTextContent('Slack');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import type { NodeTypeAvailabilityScope } from '@n8n/api-types';
|
||||
import { N8nButton, N8nCallout } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import ContactInstanceAdminModal from './ContactInstanceAdminModal.vue';
|
||||
import { describeAgentToolRestriction } from '../composables/useNodeTypeRestriction';
|
||||
|
||||
const { nodeTypeName, scope } = defineProps<{
|
||||
nodeTypeName: string;
|
||||
scope?: NodeTypeAvailabilityScope;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const isContactAdminOpen = ref(false);
|
||||
|
||||
const description = computed(() => describeAgentToolRestriction(nodeTypeName, scope));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<N8nCallout theme="warning" data-test-id="restricted-tool-callout">
|
||||
{{ description }}
|
||||
<template #trailingContent>
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
data-test-id="restricted-tool-contact-admin"
|
||||
@click="isContactAdminOpen = true"
|
||||
>
|
||||
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
</N8nCallout>
|
||||
<ContactInstanceAdminModal v-model:open="isContactAdminOpen" :node-type-name="nodeTypeName" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
describeAgentToolRestriction,
|
||||
getCredentialTypeRestriction,
|
||||
getNodeTypeRestriction,
|
||||
isNodeTypeRestricted,
|
||||
|
|
@ -109,4 +110,16 @@ describe('useNodeTypeRestriction', () => {
|
|||
expect(restrictionScope.value).toBe('project');
|
||||
});
|
||||
});
|
||||
|
||||
it('describes an agent tool restriction by scope', () => {
|
||||
expect(describeAgentToolRestriction('Slack', 'instance')).toBe(
|
||||
"Slack is restricted on this instance. The agent can't use it.",
|
||||
);
|
||||
expect(describeAgentToolRestriction('Slack', 'project')).toBe(
|
||||
"Slack is restricted in this project. The agent can't use it.",
|
||||
);
|
||||
expect(describeAgentToolRestriction('Slack')).toBe(
|
||||
"Slack is restricted. The agent can't use it.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,6 +73,23 @@ export function describeNodeTypeRestriction(
|
|||
);
|
||||
}
|
||||
|
||||
const AGENT_TOOL_DESCRIPTION_KEY: Record<NodeTypeAvailabilityScope, BaseTextKey> = {
|
||||
instance: 'typeAvailabilityPolicies.restrictedNode.agentTool.instance',
|
||||
project: 'typeAvailabilityPolicies.restrictedNode.agentTool.project',
|
||||
};
|
||||
|
||||
export function describeAgentToolRestriction(
|
||||
nodeTypeName: string,
|
||||
scope?: NodeTypeAvailabilityScope,
|
||||
): string {
|
||||
return useI18n().baseText(
|
||||
scope
|
||||
? AGENT_TOOL_DESCRIPTION_KEY[scope]
|
||||
: 'typeAvailabilityPolicies.restrictedNode.agentTool.generic',
|
||||
{ interpolate: { nodeType: nodeTypeName } },
|
||||
);
|
||||
}
|
||||
|
||||
export function useNodeTypeRestriction(nodeType: MaybeRefOrGetter<string | null | undefined>) {
|
||||
const restriction = computed(() => {
|
||||
const type = toValue(nodeType);
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ export {
|
|||
} from './composables/useNodeTypeRestriction';
|
||||
export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue';
|
||||
export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue';
|
||||
export { default as RestrictedToolCallout } from './components/RestrictedToolCallout.vue';
|
||||
export { getPolicyViolations } from './policy-violations/policyViolations';
|
||||
export { default as PolicyViolationList } from './policy-violations/PolicyViolationList.vue';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user