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: '' },
+ AgentChatEmptyState: { template: '' },
+ AgentChatMessageList: { template: '' },
+ ChatInputBase: {
+ name: 'ChatInputBase',
+ template: '',
+ props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
+ },
+ },
+ },
+ });
+
+ const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
+ expect(chatInput.props('disabled')).toBe(true);
+ expect(chatInput.props('canSubmit')).toBe(false);
+ expect(chatInput.props('placeholder')).toBe('agents.builder.readonly.placeholder');
+ });
+
+ it('does not disable ChatInputBase for endpoint=chat (test mode) regardless of canEditAgent', async () => {
+ const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
+
+ const wrapper = mount(AgentChatPanel, {
+ props: {
+ projectId: 'p1',
+ agentId: 'a1',
+ endpoint: 'chat',
+ agentConfig: null,
+ agentStatus: 'production',
+ connectedTriggers: [],
+ canEditAgent: false,
+ },
+ global: {
+ stubs: {
+ N8nButton: { template: '' },
+ N8nCallout: { template: '
' },
+ N8nIconButton: { template: '' },
+ AgentChatEmptyState: { template: '' },
+ AgentChatMessageList: { template: '' },
+ ChatInputBase: {
+ name: 'ChatInputBase',
+ template: '',
+ props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
+ },
+ },
+ },
+ });
+
+ const chatInput = wrapper.findComponent({ name: 'ChatInputBase' });
+ expect(chatInput.props('disabled')).toBe(false);
+ expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder');
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
index 70a05cd0644..4a581f3ff9c 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts
@@ -117,18 +117,23 @@ describe('AgentBuilderHeader', () => {
});
it('renders breadcrumbs, publish and action dropdown', () => {
- const wrapper = mountHeader();
+ const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
expect(wrapper.find('[data-testid="stub-breadcrumbs"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="stub-publish"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(true);
});
it('uses the horizontal dots action menu icon', () => {
- const wrapper = mountHeader();
+ const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete' }] });
const action = wrapper.findComponent({ name: 'ActionDropdown' });
expect(action.props('activatorIcon')).toBe('ellipsis');
});
+ it('hides the action dropdown when no header actions are available', () => {
+ const wrapper = mountHeader({ headerActions: [] });
+ expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(false);
+ });
+
it('passes a single project breadcrumb (agent rendered as switcher button)', () => {
const wrapper = mountHeader();
const bc = wrapper.findComponent({ name: 'N8nBreadcrumbs' });
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts
new file mode 100644
index 00000000000..37da75cf763
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCard.test.ts
@@ -0,0 +1,166 @@
+/* eslint-disable import-x/no-extraneous-dependencies, @typescript-eslint/no-unsafe-assignment -- test-only patterns: @vue/test-utils is a transitive devDep, mock reads */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { ref } from 'vue';
+import type { AgentResource } from '../types';
+import type { AgentPublishedVersion } from '../agent.types';
+
+vi.mock('../composables/useAgentApi', () => ({
+ deleteAgent: vi.fn(),
+}));
+
+vi.mock('../composables/useAgentConfirmationModal', () => ({
+ useAgentConfirmationModal: () => ({ openAgentConfirmationModal: vi.fn() }),
+}));
+
+vi.mock('../composables/useAgentPublish', () => ({
+ useAgentPublish: () => ({ publish: vi.fn(), unpublish: vi.fn() }),
+}));
+
+vi.mock('@n8n/stores/useRootStore', () => ({
+ useRootStore: () => ({ restApiContext: {} }),
+}));
+
+vi.mock('@n8n/i18n', () => ({
+ useI18n: () => ({ baseText: (key: string) => key }),
+}));
+
+const agentPermissionsMock = {
+ canCreate: ref(true),
+ canUpdate: ref(true),
+ canDelete: ref(true),
+ canPublish: ref(true),
+ canUnpublish: ref(true),
+};
+
+vi.mock('../composables/useAgentPermissions', () => ({
+ useAgentPermissions: () => agentPermissionsMock,
+}));
+
+// First mount eats the SFC transform cost for AgentCard + deps; give the
+// whole suite headroom.
+vi.setConfig({ testTimeout: 30_000 });
+
+const STUBS = {
+ N8nCard: {
+ template:
+ '
',
+ },
+ N8nText: { template: '
' },
+ N8nBadge: {
+ template: '',
+ props: ['theme', 'bold'],
+ },
+ N8nActionToggle: {
+ name: 'N8nActionToggle',
+ 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)"
>
-
+
+import { computed } from 'vue';
import { N8nCard, N8nRadioButtons } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@@ -14,7 +15,7 @@ import AgentJsonEditor from './AgentJsonEditor.vue';
import AgentMemoryPanel from './AgentMemoryPanel.vue';
import AgentPanelHeader from './AgentPanelHeader.vue';
-defineProps<{
+const props = defineProps<{
activeMainTab: AgentBuilderMainTab;
mainTabOptions: Array<{ label: string; value: AgentBuilderMainTab }>;
localConfig: AgentJsonConfig | null;
@@ -24,9 +25,12 @@ defineProps<{
appliedSkills: Array<{ id: string; skill: AgentSkill }>;
connectedTriggers: string[];
isBuildChatStreaming: boolean;
+ canEditAgent: boolean;
executionsDescription: string;
}>();
+const childrenDisabled = computed(() => props.isBuildChatStreaming || !props.canEditAgent);
+
const emit = defineEmits<{
'update:activeMainTab': [tab: AgentBuilderMainTab];
'update:config': [updates: Partial];
@@ -57,7 +61,7 @@ const i18n = useI18n();
@@ -125,7 +129,7 @@ const i18n = useI18n();
@@ -134,7 +138,7 @@ const i18n = useI18n();
@@ -149,7 +153,7 @@ const i18n = useI18n();
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
index 6eacc05509c..f2ea10cb22a 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue
@@ -146,6 +146,7 @@ function onBreadcrumbSelect(item: PathItem) {
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
import { computed } from 'vue';
import dateformat from 'dateformat';
-import { N8nActionToggle, N8nCard, N8nText } from '@n8n/design-system';
+import { N8nActionToggle, N8nBadge, N8nCard, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { MODAL_CONFIRM } from '@/app/constants';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { deleteAgent } from '../composables/useAgentApi';
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
+import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentPublish } from '../composables/useAgentPublish';
import type { AgentResource } from '../types';
@@ -27,18 +28,34 @@ const locale = useI18n();
const rootStore = useRootStore();
const { openAgentConfirmationModal } = useAgentConfirmationModal();
const { publish, unpublish } = useAgentPublish();
+const { canUpdate, canDelete, canPublish, canUnpublish } = useAgentPermissions(
+ () => props.projectId,
+);
const isPublished = computed(() => props.agent.publishedVersion !== null);
const actions = computed(() => {
- return [
- isPublished.value
- ? { value: 'unpublish', label: locale.baseText('agents.list.actions.unpublish') }
- : { value: 'publish', label: locale.baseText('agents.list.actions.publish') },
- { value: 'delete', label: locale.baseText('agents.list.actions.delete'), divided: true },
- ];
+ const items: Array<{ value: string; label: string; divided?: boolean }> = [];
+
+ if (isPublished.value && canUnpublish.value) {
+ items.push({ value: 'unpublish', label: locale.baseText('agents.list.actions.unpublish') });
+ } else if (!isPublished.value && canPublish.value) {
+ items.push({ value: 'publish', label: locale.baseText('agents.list.actions.publish') });
+ }
+
+ if (canDelete.value) {
+ items.push({
+ value: 'delete',
+ label: locale.baseText('agents.list.actions.delete'),
+ divided: items.length > 0,
+ });
+ }
+
+ return items;
});
+const showActions = computed(() => actions.value.length > 0);
+
const formattedCreatedAtDate = computed(() => {
const currentYear = new Date().getFullYear().toString();
@@ -78,6 +95,15 @@ async function onAction(action: string) {
{{ agent.name }}
+
+ {{ locale.baseText('agents.list.readonly') }}
+
@@ -100,6 +126,7 @@ async function onAction(action: string) {
Promise | void;
inputDraft?: string;
}>(),
@@ -31,6 +32,7 @@ const props = withDefaults(
endpoint: 'chat',
initialMessage: undefined,
continueSessionId: undefined,
+ canEditAgent: true,
beforeSend: undefined,
inputDraft: undefined,
},
@@ -111,10 +113,14 @@ const hasOpenInteractiveQuestion = computed(() =>
messages.value.some((message) => message.interactive && !message.interactive.resolvedAt),
);
+const isBuilderReadOnly = computed(() => props.endpoint === 'build' && !props.canEditAgent);
+
const chatPlaceholder = computed(() =>
- hasOpenInteractiveQuestion.value
- ? locale.baseText('agents.chat.answerQuestionPlaceholder')
- : locale.baseText('agents.chat.input.placeholder'),
+ isBuilderReadOnly.value
+ ? locale.baseText('agents.builder.readonly.placeholder')
+ : hasOpenInteractiveQuestion.value
+ ? locale.baseText('agents.chat.answerQuestionPlaceholder')
+ : locale.baseText('agents.chat.input.placeholder'),
);
function onOpenBuild() {
@@ -126,9 +132,14 @@ watch(isStreaming, (v) => emit('update:streaming', v));
async function onSubmit() {
const text = inputText.value.trim();
- if (!text || isStreaming.value || isPreparingToSend.value || hasOpenInteractiveQuestion.value) {
+ if (
+ !text ||
+ isStreaming.value ||
+ isPreparingToSend.value ||
+ isBuilderReadOnly.value ||
+ hasOpenInteractiveQuestion.value
+ )
return;
- }
isPreparingToSend.value = true;
try {
@@ -276,9 +287,11 @@ onBeforeUnmount(() => {
!hasOpenInteractiveQuestion &&
!isStreaming &&
!isPreparingToSend &&
+ !isBuilderReadOnly &&
inputText.trim().length > 0
"
:disabled="
+ isBuilderReadOnly ||
hasOpenInteractiveQuestion ||
isPreparingToSend ||
(isStreaming && messagingState !== 'receiving')
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentPublishButton.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentPublishButton.vue
index 17a72cbcdae..8f1ce4f5c2c 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentPublishButton.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentPublishButton.vue
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { N8nActionDropdown, N8nButton, N8nIconButton } from '@n8n/design-system';
import type { ActionDropdownItem } from '@n8n/design-system/types/action-dropdown';
import { useI18n } from '@n8n/i18n';
+import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentPublish } from '../composables/useAgentPublish';
import type { AgentResource } from '../types';
@@ -14,6 +15,8 @@ const props = defineProps<{
beforeRevertToPublished?: () => Promise | void;
}>();
+const { canUpdate, canPublish, canUnpublish } = useAgentPermissions(() => props.projectId);
+
const emit = defineEmits<{
published: [agent: AgentResource];
unpublished: [agent: AgentResource];
@@ -64,7 +67,8 @@ const dropdownActions = computed(() => {
{
id: 'publish',
label: locale.baseText('agents.publish.dropdown.publish'),
- disabled: !buttonConfig.value.enabled || publishing.value || props.isSaving,
+ disabled:
+ !buttonConfig.value.enabled || publishing.value || props.isSaving || !canPublish.value,
},
];
@@ -72,14 +76,15 @@ const dropdownActions = computed(() => {
actions.push({
id: 'revert-to-published',
label: locale.baseText('agents.publish.dropdown.revertToPublished'),
- disabled: publishing.value || props.isSaving,
+ disabled: publishing.value || props.isSaving || !canUpdate.value,
});
}
actions.push({
id: 'unpublish',
label: locale.baseText('agents.publish.dropdown.unpublish'),
- disabled: !props.agent?.publishedVersion || publishing.value || props.isSaving,
+ disabled:
+ !props.agent?.publishedVersion || publishing.value || props.isSaving || !canUnpublish.value,
divided: true,
});
@@ -87,7 +92,7 @@ const dropdownActions = computed(() => {
});
async function onPublishClick() {
- if (!buttonConfig.value.enabled || props.isSaving) return;
+ if (!buttonConfig.value.enabled || props.isSaving || !canPublish.value) return;
const updated = await publish(props.projectId, props.agentId);
if (updated) emit('published', updated);
}
@@ -98,13 +103,13 @@ async function onDropdownSelect(action: string) {
return;
}
if (action === 'revert-to-published') {
- if (!props.agent?.publishedVersion || props.isSaving) return;
+ if (!props.agent?.publishedVersion || props.isSaving || !canUpdate.value) return;
await props.beforeRevertToPublished?.();
const updated = await revertToPublished(props.projectId, props.agentId);
if (updated) emit('reverted', updated);
return;
}
- if (action !== 'unpublish') return;
+ if (action !== 'unpublish' || !canUnpublish.value) return;
const updated = await unpublish(props.projectId, props.agentId, props.agent?.name);
if (updated) emit('unpublished', updated);
}
@@ -115,7 +120,7 @@ async function onDropdownSelect(action: string) {
}`, ComputedRef>;
+
+export function useAgentPermissions(
+ projectId: MaybeRefOrGetter,
+): AgentPermissions {
+ const projectsStore = useProjectsStore();
+ const usersStore = useUsersStore();
+
+ const projectScopes = computed(
+ () =>
+ getResourcePermissions(
+ projectsStore.myProjects?.find((p) => p.id === toValue(projectId))?.scopes,
+ ).agent,
+ );
+ const globalScopes = computed(
+ () => getResourcePermissions(usersStore.currentUser?.globalScopes).agent,
+ );
+
+ const pick = (key: AgentPermissionKey): ComputedRef =>
+ computed(() => Boolean(globalScopes.value[key] ?? projectScopes.value[key]));
+
+ return {
+ canCreate: pick('create'),
+ canUpdate: pick('update'),
+ canDelete: pick('delete'),
+ canPublish: pick('publish'),
+ canUnpublish: pick('unpublish'),
+ };
+}
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
index 12dc81644ca..ccf3a5ca680 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
@@ -26,6 +26,7 @@ import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetr
import { useAgentConfirmationModal } from '../composables/useAgentConfirmationModal';
import { useAgentConfig } from '../composables/useAgentConfig';
import { useAgentBuilderStatus } from '../composables/useAgentBuilderStatus';
+import { useAgentPermissions } from '../composables/useAgentPermissions';
import { useAgentSessionsStore } from '../agentSessions.store';
import { useAgentBuilderSession } from '../composables/useAgentBuilderSession';
import { useAgentChatMode, type ChatMode } from '../composables/useAgentChatMode';
@@ -68,6 +69,8 @@ const projectId = computed(
);
const agentId = computed(() => route.params.agentId as string);
+const { canUpdate: canEditAgent, canDelete: canDeleteAgent } = useAgentPermissions(projectId);
+
// UI state
const {
chatMode,
@@ -457,7 +460,11 @@ async function onConfigUpdated() {
builderTelemetry.trackSkillsAdded();
}
-const headerActions = [{ id: 'delete', label: locale.baseText('agents.builder.deleteAgent') }];
+const headerActions = computed(() =>
+ canDeleteAgent.value
+ ? [{ id: 'delete', label: locale.baseText('agents.builder.deleteAgent') }]
+ : [],
+);
async function onHeaderAction(action: string) {
if (action === 'delete') {
@@ -894,6 +901,7 @@ function onSwitchAgent(nextAgentId: string) {
:is-build-chat-streaming="isBuildChatStreaming"
:is-published="Boolean(agent?.publishedVersion)"
:is-full-width="isChatFullWidth"
+ :can-edit-agent="canEditAgent"
:before-build-send="flushAutosave"
@session-select="onSessionPick"
@new-chat="onNewChat"
@@ -921,6 +929,7 @@ function onSwitchAgent(nextAgentId: string) {
:applied-skills="appliedSkills"
:connected-triggers="connectedTriggers"
:is-build-chat-streaming="isBuildChatStreaming"
+ :can-edit-agent="canEditAgent"
:main-tab-options="mainTabOptions"
:executions-description="executionsDescription"
@update:config="onConfigFieldUpdate"
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
index f48eecbc987..51a3e4d93ff 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.test.ts
@@ -306,7 +306,9 @@ describe('ProjectHeader', () => {
describe('new agent telemetry', () => {
beforeEach(() => {
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
- projectsStore.currentProject = createTestProject({ scopes: ['workflow:create'] });
+ projectsStore.currentProject = createTestProject({
+ scopes: ['workflow:create', 'agent:create'],
+ });
});
it('tracks source=button when the agent main button is clicked', async () => {
@@ -632,5 +634,26 @@ describe('ProjectHeader', () => {
expect(queryByTestId('add-resource-variable')).toBeDisabled();
});
+
+ it('should enable agent create button when project scope allows it', () => {
+ settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
+ const project = createTestProject({ scopes: ['agent:create'] });
+ projectsStore.currentProject = project;
+
+ const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
+
+ expect(getByTestId('add-resource-agent')).toBeInTheDocument();
+ expect(getByTestId('add-resource-agent')).toBeEnabled();
+ });
+
+ it('should disable agent create button when no scope allows it', () => {
+ settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
+ const project = createTestProject({ scopes: [] });
+ projectsStore.currentProject = project;
+
+ const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
+
+ expect(getByTestId('add-resource-agent')).toBeDisabled();
+ });
});
});
diff --git a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
index 2ace56252bd..75ce07245f6 100644
--- a/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
+++ b/packages/frontend/editor-ui/src/features/collaboration/projects/components/ProjectHeader.vue
@@ -200,7 +200,9 @@ const createAgentButton = computed(() => ({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
size: 'mini' as const,
- disabled: sourceControlStore.preferences.branchReadOnly,
+ disabled:
+ sourceControlStore.preferences.branchReadOnly ||
+ !getResourcePermissions(homeProject.value?.scopes).agent.create,
}));
const selectedMainButtonType = computed(() => {
@@ -295,7 +297,9 @@ const menu = computed(() => {
items.push({
value: ACTION_TYPES.AGENT,
label: i18n.baseText('projects.header.create.agent'),
- disabled: sourceControlStore.preferences.branchReadOnly,
+ disabled:
+ sourceControlStore.preferences.branchReadOnly ||
+ !getResourcePermissions(homeProject.value?.scopes).agent.create,
});
}