feat(editor): Show the restriction callout and lock the agent tool configuration form (#40657)

This commit is contained in:
Theodore Nastos 2026-10-09 08:09:15 +00:00 committed by GitHub
parent 6f5f79321b
commit 01da88958d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 358 additions and 24 deletions

View File

@ -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",

View File

@ -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);
});
});
});

View File

@ -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');
});
});

View File

@ -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();

View File

@ -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"

View File

@ -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)"
/>

View File

@ -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"
/>

View File

@ -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"

View File

@ -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>

View File

@ -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)"

View File

@ -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"
>

View File

@ -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"

View File

@ -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);

View File

@ -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 {

View File

@ -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,

View File

@ -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');
});
});

View File

@ -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>

View File

@ -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.",
);
});
});

View File

@ -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);

View File

@ -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';