feat(editor): Disable Agent editing UI when user lacks agent:update (no-changelog) (#30201)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
Eugene 2026-05-12 09:58:12 +02:00 committed by GitHub
parent 3297536011
commit df6e39bddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 630 additions and 33 deletions

View File

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

View File

@ -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: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },
N8nRadioButtons: { template: '<div />' },
N8nText: { template: '<span><slot /></span>' },
AgentIdentityHeader: {
name: 'AgentIdentityHeader',
template: '<div data-testid="stub-identity" />',
props: ['config', 'disabled'],
},
AgentInfoPanel: {
name: 'AgentInfoPanel',
template: '<div data-testid="stub-info" />',
props: ['config', 'disabled', 'embedded'],
},
AgentMemoryPanel: {
name: 'AgentMemoryPanel',
template: '<div data-testid="stub-memory" />',
props: ['config', 'disabled', 'embedded'],
},
AgentAdvancedPanel: {
name: 'AgentAdvancedPanel',
template: '<div data-testid="stub-advanced" />',
props: ['config', 'disabled', 'collapsible'],
},
AgentCapabilitiesSection: {
name: 'AgentCapabilitiesSection',
template: '<div data-testid="stub-capabilities" />',
props: [
'config',
'tools',
'customTools',
'skills',
'connectedTriggers',
'disabled',
'projectId',
'agentId',
'isPublished',
],
},
AgentJsonEditor: {
name: 'AgentJsonEditor',
template: '<div data-testid="stub-json" />',
props: ['value', 'readOnly', 'copyButtonTestId'],
},
AgentSessionsListView: { template: '<div />' },
AgentPanelHeader: { template: '<div />', 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: '<div><slot /></div>' },
N8nHeading: { template: '<div><slot /></div>' },
N8nRadioButtons: { template: '<div />' },
N8nText: { template: '<span><slot /></span>' },
AgentJsonEditor: {
name: 'AgentJsonEditor',
template: '<div data-testid="stub-json" />',
props: ['value', 'readOnly', 'copyButtonTestId'],
},
AgentPanelHeader: { template: '<div />', 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: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
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: '<button><slot /></button>' },
N8nCallout: { template: '<div><slot /></div>' },
N8nIconButton: { template: '<button />' },
AgentChatEmptyState: { template: '<div />' },
AgentChatMessageList: { template: '<div />' },
ChatInputBase: {
name: 'ChatInputBase',
template: '<div data-testid="stub-chat-input" />',
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');
});
});

View File

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

View File

@ -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:
'<div data-test-id="agent-card"><slot name="header" /><slot /><slot name="append" /></div>',
},
N8nText: { template: '<div :data-test-id="$attrs[\'data-test-id\']"><slot /></div>' },
N8nBadge: {
template: '<span :data-test-id="$attrs[\'data-test-id\']"><slot /></span>',
props: ['theme', 'bold'],
},
N8nActionToggle: {
name: 'N8nActionToggle',
template:
'<div :data-test-id="$attrs[\'data-test-id\']"><button v-for="a in actions" :key="a.value" :data-action="a.value">{{ a.label }}</button></div>',
props: ['actions', 'theme'],
},
TimeAgo: { template: '<span />' },
};
const publishedVersion: AgentPublishedVersion = {
schema: null,
skills: null,
publishedFromVersionId: 'v1',
model: null,
provider: null,
credentialId: null,
publishedById: null,
};
function createAgent(overrides: Partial<AgentResource> = {}): 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);
});
});

View File

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

View File

@ -33,6 +33,7 @@ const props = defineProps<{
isBuildChatStreaming: boolean;
isPublished: boolean;
isFullWidth: boolean;
canEditAgent: boolean;
beforeBuildSend?: () => Promise<void> | 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)"
>
<template #above-input>
<template v-if="canEditAgent" #above-input>
<div :class="$style.quickActionsRow">
<AgentChatQuickActions
:tools="localConfig?.tools ?? []"

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
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<AgentJsonConfig>];
@ -57,7 +61,7 @@ const i18n = useI18n();
<AgentIdentityHeader
v-if="activeMainTab === 'agent'"
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
@update:config="emit('update:config', $event)"
/>
<AgentPanelHeader
@ -97,7 +101,7 @@ const i18n = useI18n();
:custom-tools="agent?.tools ?? {}"
:skills="appliedSkills"
:connected-triggers="connectedTriggers"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
:project-id="projectId"
:agent-id="agentId"
:is-published="Boolean(agent?.publishedVersion)"
@ -116,7 +120,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentInfoPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
embedded
@update:config="emit('update:config', $event)"
/>
@ -125,7 +129,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentMemoryPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
embedded
@update:config="emit('update:config', $event)"
/>
@ -134,7 +138,7 @@ const i18n = useI18n();
<N8nCard variant="outlined" :class="$style.card">
<AgentAdvancedPanel
:config="localConfig"
:disabled="isBuildChatStreaming"
:disabled="childrenDisabled"
collapsible
@update:config="emit('update:config', $event)"
/>
@ -149,7 +153,7 @@ const i18n = useI18n();
<div v-else-if="activeMainTab === 'raw'" :class="$style.rawPanel">
<AgentJsonEditor
:value="localConfig"
:read-only="isBuildChatStreaming"
:read-only="childrenDisabled"
copy-button-test-id="agent-config-json-copy"
@update:value="emit('update:config', $event)"
/>

View File

@ -146,6 +146,7 @@ function onBreadcrumbSelect(item: PathItem) {
@reverted="(a: AgentResource) => emit('reverted', a)"
/>
<N8nActionDropdown
v-if="headerActions.length > 0"
:items="headerActions"
activator-icon="ellipsis"
activator-size="medium"

View File

@ -1,13 +1,14 @@
<script setup lang="ts">
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) {
<template #header>
<N8nText tag="h2" bold :class="$style.cardHeading" data-test-id="agent-card-name">
{{ agent.name }}
<N8nBadge
v-if="!canUpdate"
:class="$style.readonlyBadge"
theme="tertiary"
bold
data-test-id="agent-card-readonly-badge"
>
{{ locale.baseText('agents.list.readonly') }}
</N8nBadge>
</N8nText>
</template>
<div :class="$style.cardDescription">
@ -100,6 +126,7 @@ async function onAction(action: string) {
</N8nText>
</div>
<N8nActionToggle
v-if="showActions"
:actions="actions"
theme="dark"
data-test-id="agent-card-actions"
@ -130,6 +157,10 @@ async function onAction(action: string) {
padding: var(--spacing--sm) 0 0 var(--spacing--sm);
}
.readonlyBadge {
margin-left: var(--spacing--3xs);
}
.cardDescription {
min-height: var(--spacing--xl);
display: flex;

View File

@ -22,6 +22,7 @@ const props = withDefaults(
agentConfig: AgentJsonConfig | null;
agentStatus: 'draft' | 'production';
connectedTriggers: string[];
canEditAgent?: boolean;
beforeSend?: () => Promise<void> | 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')

View File

@ -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> | 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) {
<N8nButton
:class="$style.groupButtonLeft"
:loading="publishing"
:disabled="!buttonConfig.enabled || isSaving"
:disabled="!buttonConfig.enabled || isSaving || !canPublish"
variant="ghost"
data-testid="publish-agent-button"
@click="onPublishClick"

View File

@ -0,0 +1,36 @@
import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue';
import { getResourcePermissions } from '@n8n/permissions';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useUsersStore } from '@/features/settings/users/users.store';
type AgentPermissionKey = 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
export type AgentPermissions = Record<`can${Capitalize<AgentPermissionKey>}`, ComputedRef<boolean>>;
export function useAgentPermissions(
projectId: MaybeRefOrGetter<string | undefined>,
): 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<boolean> =>
computed(() => Boolean(globalScopes.value[key] ?? projectScopes.value[key]));
return {
canCreate: pick('create'),
canUpdate: pick('update'),
canDelete: pick('delete'),
canPublish: pick('publish'),
canUnpublish: pick('unpublish'),
};
}

View File

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

View File

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

View File

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