mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
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:
parent
3297536011
commit
df6e39bddf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? []"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user