From df6e39bddfeeaa4af2c9ba5626b1b2576800a5b9 Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 12 May 2026 09:58:12 +0200 Subject: [PATCH] feat(editor): Disable Agent editing UI when user lacks agent:update (no-changelog) (#30201) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: yehorkardash --- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../__tests__/AgentBuilder.readonly.test.ts | 223 ++++++++++++++++++ .../__tests__/AgentBuilderHeader.test.ts | 9 +- .../agents/__tests__/AgentCard.test.ts | 166 +++++++++++++ .../__tests__/AgentPublishButton.test.ts | 73 ++++++ .../components/AgentBuilderChatColumn.vue | 4 +- .../components/AgentBuilderEditorColumn.vue | 18 +- .../agents/components/AgentBuilderHeader.vue | 1 + .../features/agents/components/AgentCard.vue | 45 +++- .../agents/components/AgentChatPanel.vue | 23 +- .../agents/components/AgentPublishButton.vue | 19 +- .../agents/composables/useAgentPermissions.ts | 36 +++ .../agents/views/AgentBuilderView.vue | 11 +- .../projects/components/ProjectHeader.test.ts | 25 +- .../projects/components/ProjectHeader.vue | 8 +- 15 files changed, 630 insertions(+), 33 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts create mode 100644 packages/frontend/editor-ui/src/features/agents/composables/useAgentPermissions.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 76c8e1a6330..225b346aaff 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5840,6 +5840,7 @@ "agents.list.actions.publish": "Publish", "agents.list.actions.unpublish": "Unpublish", "agents.list.actions.delete": "Delete", + "agents.list.readonly": "Read only", "agents.publish.button.publish": "Publish", "agents.publish.button.published": "Published", "agents.publish.dropdown.publish": "Publish", @@ -5906,6 +5907,7 @@ "agents.new.headingWithName": "What should we build, {name}?", "agents.new.description.placeholder": "Describe your agent...", "agents.new.templates.label": "Or try a template", + "agents.builder.readonly.placeholder": "You don't have permission to edit this agent", "agents.builder.sections.agent": "Agent", "agents.builder.sections.advanced": "Advanced", "agents.builder.sections.configJson": "Raw", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts new file mode 100644 index 00000000000..0b25afbec60 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilder.readonly.test.ts @@ -0,0 +1,223 @@ +/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns */ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (key: string) => key }), + i18n: { baseText: (key: string) => key }, +})); + +// First mount of these SFCs eats the Vite transform cost; give them headroom. +vi.setConfig({ testTimeout: 30_000 }); + +describe('AgentBuilderEditorColumn — childrenDisabled composes streaming and canEditAgent', () => { + it('child panels see disabled=true when canEditAgent is false', async () => { + const { default: AgentBuilderEditorColumn } = await import( + '../components/AgentBuilderEditorColumn.vue' + ); + + const wrapper = mount(AgentBuilderEditorColumn, { + props: { + activeMainTab: 'agent', + mainTabOptions: [{ label: 'Agent', value: 'agent' }], + localConfig: {} as never, + agent: null, + projectId: 'p1', + agentId: 'a1', + appliedSkills: [], + connectedTriggers: [], + isBuildChatStreaming: false, + canEditAgent: false, // <<< Agent is read only + executionsDescription: '', + }, + global: { + stubs: { + N8nCard: { template: '
' }, + N8nHeading: { template: '
' }, + N8nRadioButtons: { template: '
' }, + N8nText: { template: '' }, + AgentIdentityHeader: { + name: 'AgentIdentityHeader', + template: '
', + props: ['config', 'disabled'], + }, + AgentInfoPanel: { + name: 'AgentInfoPanel', + template: '
', + props: ['config', 'disabled', 'embedded'], + }, + AgentMemoryPanel: { + name: 'AgentMemoryPanel', + template: '
', + props: ['config', 'disabled', 'embedded'], + }, + AgentAdvancedPanel: { + name: 'AgentAdvancedPanel', + template: '
', + props: ['config', 'disabled', 'collapsible'], + }, + AgentCapabilitiesSection: { + name: 'AgentCapabilitiesSection', + template: '
', + props: [ + 'config', + 'tools', + 'customTools', + 'skills', + 'connectedTriggers', + 'disabled', + 'projectId', + 'agentId', + 'isPublished', + ], + }, + AgentJsonEditor: { + name: 'AgentJsonEditor', + template: '
', + props: ['value', 'readOnly', 'copyButtonTestId'], + }, + AgentSessionsListView: { template: '
' }, + AgentPanelHeader: { template: '
', props: ['title', 'description'] }, + }, + }, + }); + + expect(wrapper.findComponent({ name: 'AgentIdentityHeader' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ name: 'AgentInfoPanel' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ name: 'AgentMemoryPanel' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ name: 'AgentAdvancedPanel' }).props('disabled')).toBe(true); + expect(wrapper.findComponent({ name: 'AgentCapabilitiesSection' }).props('disabled')).toBe( + true, + ); + }); + + it('JSON editor receives readOnly=true when canEditAgent is false', async () => { + const { default: AgentBuilderEditorColumn } = await import( + '../components/AgentBuilderEditorColumn.vue' + ); + + const wrapper = mount(AgentBuilderEditorColumn, { + props: { + activeMainTab: 'raw', + mainTabOptions: [{ label: 'Raw', value: 'raw' }], + localConfig: {} as never, + agent: null, + projectId: 'p1', + agentId: 'a1', + appliedSkills: [], + connectedTriggers: [], + isBuildChatStreaming: false, + canEditAgent: false, + executionsDescription: '', + }, + global: { + stubs: { + N8nCard: { template: '
' }, + N8nHeading: { template: '
' }, + N8nRadioButtons: { template: '
' }, + N8nText: { template: '' }, + AgentJsonEditor: { + name: 'AgentJsonEditor', + template: '
', + props: ['value', 'readOnly', 'copyButtonTestId'], + }, + AgentPanelHeader: { template: '
', props: ['title', 'description'] }, + }, + }, + }); + + expect(wrapper.findComponent({ name: 'AgentJsonEditor' }).props('readOnly')).toBe(true); + }); +}); + +describe('AgentChatPanel — read-only build chat input', () => { + it('disables ChatInputBase when endpoint=build and canEditAgent=false', async () => { + vi.doMock('../composables/useAgentChatStream', () => ({ + useAgentChatStream: () => ({ + messages: ref([]), + isStreaming: ref(false), + messagingState: ref('idle'), + fatalError: ref(null), + loadHistory: vi.fn(), + sendMessage: vi.fn(), + stopGenerating: vi.fn(), + resume: vi.fn(), + dismissFatalError: vi.fn(), + }), + })); + vi.doMock('../composables/useAgentTelemetry', () => ({ + useAgentTelemetry: () => ({ trackSubmittedMessage: vi.fn() }), + })); + vi.doMock('../composables/agentTelemetry.utils', () => ({ + buildAgentConfigFingerprint: vi.fn().mockResolvedValue({}), + })); + + const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue'); + + const wrapper = mount(AgentChatPanel, { + props: { + projectId: 'p1', + agentId: 'a1', + endpoint: 'build', + agentConfig: null, + agentStatus: 'draft', + connectedTriggers: [], + canEditAgent: false, + }, + global: { + stubs: { + N8nButton: { template: '' }, + N8nCallout: { template: '
' }, + N8nIconButton: { template: '' }, + N8nCallout: { template: '
' }, + N8nIconButton: { template: '
', + props: ['actions', 'theme'], + }, + TimeAgo: { template: '' }, +}; + +const publishedVersion: AgentPublishedVersion = { + schema: null, + skills: null, + publishedFromVersionId: 'v1', + model: null, + provider: null, + credentialId: null, + publishedById: null, +}; + +function createAgent(overrides: Partial = {}): AgentResource { + return { + resourceType: 'agent', + id: 'agent-1', + name: 'My Agent', + description: null, + projectId: 'project-1', + credentialId: null, + provider: null, + model: null, + isCompiled: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + versionId: 'v1', + tools: {}, + skills: {}, + publishedVersion: null, + ...overrides, + }; +} + +async function renderComponent(agent: AgentResource = createAgent()) { + const { default: AgentCard } = await import('../components/AgentCard.vue'); + return mount(AgentCard, { + props: { agent, projectId: 'project-1' }, + global: { stubs: STUBS }, + }); +} + +describe('AgentCard', () => { + beforeEach(() => { + agentPermissionsMock.canUpdate.value = true; + agentPermissionsMock.canDelete.value = true; + agentPermissionsMock.canPublish.value = true; + agentPermissionsMock.canUnpublish.value = true; + }); + + it('hides the read-only badge when canUpdate is true', async () => { + const wrapper = await renderComponent(); + expect(wrapper.find('[data-test-id="agent-card-readonly-badge"]').exists()).toBe(false); + }); + + it('shows the read-only badge when canUpdate is false', async () => { + agentPermissionsMock.canUpdate.value = false; + const wrapper = await renderComponent(); + + const badge = wrapper.find('[data-test-id="agent-card-readonly-badge"]'); + expect(badge.exists()).toBe(true); + expect(badge.text()).toBe('agents.list.readonly'); + }); + + it('hides the action toggle when no scopes grant any action', async () => { + agentPermissionsMock.canDelete.value = false; + agentPermissionsMock.canPublish.value = false; + agentPermissionsMock.canUnpublish.value = false; + const wrapper = await renderComponent(); + + expect(wrapper.find('[data-test-id="agent-card-actions"]').exists()).toBe(false); + }); + + it('shows Publish + Delete on an unpublished agent with full scopes', async () => { + const wrapper = await renderComponent(createAgent({ publishedVersion: null })); + + expect(wrapper.find('[data-action="publish"]').exists()).toBe(true); + expect(wrapper.find('[data-action="delete"]').exists()).toBe(true); + expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false); + }); + + it('shows Unpublish + Delete on a published agent with full scopes', async () => { + const wrapper = await renderComponent(createAgent({ publishedVersion })); + + expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(true); + expect(wrapper.find('[data-action="delete"]').exists()).toBe(true); + expect(wrapper.find('[data-action="publish"]').exists()).toBe(false); + }); + + it('shows only Delete (no leading divider) when only canDelete is granted', async () => { + agentPermissionsMock.canPublish.value = false; + agentPermissionsMock.canUnpublish.value = false; + const wrapper = await renderComponent(); + + expect(wrapper.find('[data-action="delete"]').exists()).toBe(true); + expect(wrapper.find('[data-action="publish"]').exists()).toBe(false); + expect(wrapper.find('[data-action="unpublish"]').exists()).toBe(false); + }); + + it('hides Publish action when canPublish is false on an unpublished agent', async () => { + agentPermissionsMock.canPublish.value = false; + const wrapper = await renderComponent(createAgent({ publishedVersion: null })); + + expect(wrapper.find('[data-action="publish"]').exists()).toBe(false); + expect(wrapper.find('[data-action="delete"]').exists()).toBe(true); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts index 74beca00412..f2efcf2a02e 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentPublishButton.test.ts @@ -1,6 +1,7 @@ /* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/require-await, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep, async stubs, and any-based mock reads */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount, flushPromises } from '@vue/test-utils'; +import { ref } from 'vue'; import type { AgentResource } from '../types'; import type { AgentPublishedVersion } from '../agent.types'; @@ -17,6 +18,18 @@ vi.mock('../composables/useAgentTelemetry', () => ({ }), })); +const agentPermissionsMock = { + canCreate: ref(true), + canUpdate: ref(true), + canDelete: ref(true), + canPublish: ref(true), + canUnpublish: ref(true), +}; + +vi.mock('../composables/useAgentPermissions', () => ({ + useAgentPermissions: () => agentPermissionsMock, +})); + vi.mock('../composables/agentTelemetry.utils', () => ({ buildAgentConfigFingerprint: vi.fn().mockResolvedValue({ config_version: 'v-test' }), })); @@ -367,4 +380,64 @@ describe('AgentPublishButton', () => { expect(dot.classes().some((c) => c.includes('indicatorPublished'))).toBe(false); }); }); + + // Permission gating + describe('permission gating', () => { + beforeEach(() => { + agentPermissionsMock.canUpdate.value = true; + agentPermissionsMock.canPublish.value = true; + agentPermissionsMock.canUnpublish.value = true; + }); + + it('disables Publish main button and dropdown item when canPublish is false', async () => { + agentPermissionsMock.canPublish.value = false; + const wrapper = await renderComponent({ agent: createAgent({ publishedVersion: null }) }); + + expect( + wrapper.find('[data-testid="publish-agent-button"]').attributes('disabled'), + ).toBeDefined(); + expect(wrapper.find('[data-action="publish"]').attributes('disabled')).toBeDefined(); + }); + + it('disables Unpublish dropdown item when canUnpublish is false', async () => { + agentPermissionsMock.canUnpublish.value = false; + const wrapper = await renderComponent({ + agent: createAgent({ versionId: 'v1', publishedVersion }), + }); + + expect(wrapper.find('[data-action="unpublish"]').attributes('disabled')).toBeDefined(); + }); + + it('disables Revert dropdown item when canUpdate is false', async () => { + agentPermissionsMock.canUpdate.value = false; + const wrapper = await renderComponent({ + agent: createAgent({ versionId: 'v2', publishedVersion }), + }); + + expect( + wrapper.find('[data-action="revert-to-published"]').attributes('disabled'), + ).toBeDefined(); + }); + + it('does not call publishAgent when Publish is clicked without canPublish', async () => { + const { publishAgent } = await import('../composables/useAgentApi'); + agentPermissionsMock.canPublish.value = false; + const wrapper = await renderComponent({ agent: createAgent({ publishedVersion: null }) }); + + await wrapper.find('[data-testid="publish-agent-button"]').trigger('click'); + await flushPromises(); + + expect(publishAgent).not.toHaveBeenCalled(); + }); + + it('keeps publish independent from unpublish — granting only canPublish enables Publish but disables Unpublish', async () => { + agentPermissionsMock.canUnpublish.value = false; + const wrapper = await renderComponent({ + agent: createAgent({ versionId: 'v2', publishedVersion }), + }); + + expect(wrapper.find('[data-action="publish"]').attributes('disabled')).toBeUndefined(); + expect(wrapper.find('[data-action="unpublish"]').attributes('disabled')).toBeDefined(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue index 5ac2d0a3698..4619155e7b3 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderChatColumn.vue @@ -33,6 +33,7 @@ const props = defineProps<{ isBuildChatStreaming: boolean; isPublished: boolean; isFullWidth: boolean; + canEditAgent: boolean; beforeBuildSend?: () => Promise | void; }>(); @@ -193,11 +194,12 @@ const sharedInputDraft = ref(''); :agent-config="localConfig" :agent-status="deriveAgentStatus(agent)" :connected-triggers="connectedTriggers" + :can-edit-agent="canEditAgent" :before-send="beforeBuildSend" @config-updated="emit('config-updated')" @update:streaming="emit('update:streaming', $event)" > -