mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
fix(editor): Follow-up changes for Agent permission gates (no-changelog) (#30632)
This commit is contained in:
parent
aed9bf837d
commit
6ba2d50eeb
|
|
@ -187,6 +187,78 @@ describe('AgentChatPanel — read-only build chat input', () => {
|
|||
expect(chatInput.props('placeholder')).toBe('agents.builder.readonly.placeholder');
|
||||
});
|
||||
|
||||
it('does not auto-send a seeded initialMessage when the build chat is read-only', async () => {
|
||||
const sendMessage = vi.fn();
|
||||
const loadHistory = vi.fn();
|
||||
// Reset module cache so the doMock below replaces the stream mock set up
|
||||
// by the earlier test in this describe block — without this, the cached
|
||||
// AgentChatPanel.vue would keep using the previous mock and our
|
||||
// `loadHistory` assertion would observe zero calls on the wrong fn.
|
||||
vi.resetModules();
|
||||
vi.doMock('../composables/useAgentChatStream', () => ({
|
||||
useAgentChatStream: () => ({
|
||||
messages: ref([]),
|
||||
isStreaming: ref(false),
|
||||
messagingState: ref('idle'),
|
||||
fatalError: ref(null),
|
||||
loadHistory,
|
||||
sendMessage,
|
||||
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 beforeSend = vi.fn();
|
||||
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,
|
||||
initialMessage: 'seed build prompt',
|
||||
beforeSend,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
N8nButton: { template: '<button><slot /></button>' },
|
||||
N8nCallout: { template: '<div><slot /></div>' },
|
||||
N8nIconButton: { template: '<button />' },
|
||||
AgentChatEmptyState: { template: '<div data-testid="stub-empty-state" />' },
|
||||
AgentChatMessageList: { template: '<div />' },
|
||||
ChatInputBase: {
|
||||
name: 'ChatInputBase',
|
||||
template: '<div data-testid="stub-chat-input" />',
|
||||
props: ['modelValue', 'placeholder', 'isStreaming', 'canSubmit', 'disabled'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// Setup-time auto-send has run if it was going to.
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
expect(beforeSend).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
|
||||
// History is loaded instead of auto-sending — so any existing thread
|
||||
// renders rather than showing a misleading "build your agent" empty state.
|
||||
expect(loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not disable ChatInputBase for endpoint=chat (test mode) regardless of canEditAgent', async () => {
|
||||
const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue');
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,37 @@ describe('AgentChatPanel', () => {
|
|||
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not consume a seeded initial message in a read-only build chat', async () => {
|
||||
const beforeSend = vi.fn();
|
||||
|
||||
const wrapper = mount(AgentChatPanel, {
|
||||
props: {
|
||||
projectId: 'p1',
|
||||
agentId: 'a1',
|
||||
endpoint: 'build',
|
||||
canEditAgent: false,
|
||||
initialMessage: 'seed build prompt',
|
||||
agentConfig: {
|
||||
name: 'Agent',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructions: 'Help.',
|
||||
},
|
||||
agentStatus: 'draft',
|
||||
connectedTriggers: [],
|
||||
beforeSend,
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(beforeSend).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted('initial-consumed')).toBeUndefined();
|
||||
// History is loaded instead so any existing thread renders, rather than
|
||||
// the misleading "describe your agent" empty state.
|
||||
expect(loadHistoryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables chat and blocks sending while an interactive question is unresolved', async () => {
|
||||
messagesMock.value = [openInteractiveMessage()];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
|
||||
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
|
||||
import { useAgentPermissions } from '../composables/useAgentPermissions';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
|
||||
const makeProject = (scopes: Scope[]): ProjectListItem => ({
|
||||
id: PROJECT_ID,
|
||||
name: 'Team Alpha',
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
type: ProjectTypes.Team,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
role: 'project:editor',
|
||||
scopes,
|
||||
});
|
||||
|
||||
describe('useAgentPermissions', () => {
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
|
||||
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
usersStore = mockedStore(useUsersStore);
|
||||
sourceControlStore = mockedStore(useSourceControlStore);
|
||||
projectsStore.myProjects = [];
|
||||
usersStore.currentUser = null;
|
||||
sourceControlStore.preferences = { branchReadOnly: false } as never;
|
||||
});
|
||||
|
||||
it('grants permissions from project scopes when global scopes are absent', () => {
|
||||
projectsStore.myProjects = [
|
||||
makeProject([
|
||||
'agent:create',
|
||||
'agent:update',
|
||||
'agent:delete',
|
||||
'agent:publish',
|
||||
'agent:unpublish',
|
||||
]),
|
||||
];
|
||||
|
||||
const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
|
||||
useAgentPermissions(PROJECT_ID);
|
||||
|
||||
expect(canCreate.value).toBe(true);
|
||||
expect(canUpdate.value).toBe(true);
|
||||
expect(canDelete.value).toBe(true);
|
||||
expect(canPublish.value).toBe(true);
|
||||
expect(canUnpublish.value).toBe(true);
|
||||
});
|
||||
|
||||
it('grants permissions from global scopes when no project is found', () => {
|
||||
usersStore.currentUser = {
|
||||
globalScopes: [
|
||||
'agent:create',
|
||||
'agent:update',
|
||||
'agent:delete',
|
||||
'agent:publish',
|
||||
'agent:unpublish',
|
||||
],
|
||||
} as never;
|
||||
|
||||
const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
|
||||
useAgentPermissions(PROJECT_ID);
|
||||
|
||||
expect(canCreate.value).toBe(true);
|
||||
expect(canUpdate.value).toBe(true);
|
||||
expect(canDelete.value).toBe(true);
|
||||
expect(canPublish.value).toBe(true);
|
||||
expect(canUnpublish.value).toBe(true);
|
||||
});
|
||||
|
||||
it('grants permissions when either scope source allows the action', () => {
|
||||
projectsStore.myProjects = [makeProject(['agent:create', 'agent:update'])];
|
||||
usersStore.currentUser = {
|
||||
globalScopes: ['agent:delete'],
|
||||
} as never;
|
||||
|
||||
const { canCreate, canUpdate, canDelete, canPublish } = useAgentPermissions(PROJECT_ID);
|
||||
|
||||
expect(canCreate.value).toBe(true);
|
||||
expect(canUpdate.value).toBe(true);
|
||||
expect(canDelete.value).toBe(true);
|
||||
expect(canPublish.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for every flag when no scopes match', () => {
|
||||
projectsStore.myProjects = [makeProject([])];
|
||||
usersStore.currentUser = { globalScopes: [] } as never;
|
||||
|
||||
const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
|
||||
useAgentPermissions(PROJECT_ID);
|
||||
|
||||
expect(canCreate.value).toBe(false);
|
||||
expect(canUpdate.value).toBe(false);
|
||||
expect(canDelete.value).toBe(false);
|
||||
expect(canPublish.value).toBe(false);
|
||||
expect(canUnpublish.value).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks every flag when source control puts the branch in read-only mode', () => {
|
||||
projectsStore.myProjects = [
|
||||
makeProject([
|
||||
'agent:create',
|
||||
'agent:update',
|
||||
'agent:delete',
|
||||
'agent:publish',
|
||||
'agent:unpublish',
|
||||
]),
|
||||
];
|
||||
usersStore.currentUser = {
|
||||
globalScopes: [
|
||||
'agent:create',
|
||||
'agent:update',
|
||||
'agent:delete',
|
||||
'agent:publish',
|
||||
'agent:unpublish',
|
||||
],
|
||||
} as never;
|
||||
sourceControlStore.preferences = { branchReadOnly: true } as never;
|
||||
|
||||
const { canCreate, canUpdate, canDelete, canPublish, canUnpublish } =
|
||||
useAgentPermissions(PROJECT_ID);
|
||||
|
||||
expect(canCreate.value).toBe(false);
|
||||
expect(canUpdate.value).toBe(false);
|
||||
expect(canDelete.value).toBe(false);
|
||||
expect(canPublish.value).toBe(false);
|
||||
expect(canUnpublish.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -203,17 +203,19 @@ async function sendSeedMessage(message: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
if (seedMessage) {
|
||||
void sendSeedMessage(seedMessage);
|
||||
// Skip the seed when the build chat is read-only
|
||||
const consumesSeed = !!seedMessage && !isBuilderReadOnly.value;
|
||||
if (consumesSeed) {
|
||||
void sendSeedMessage(seedMessage as string);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// A supplied `initialMessage` means the parent just minted a fresh session
|
||||
// and wants us to seed it with the first message — there's no thread to
|
||||
// load yet, and hitting the history endpoint would 404. The seed was
|
||||
// already sent synchronously during setup (see the `seedMessage` block
|
||||
// above).
|
||||
if (seedMessage) {
|
||||
// When we actually seeded a message, there's no prior thread to load —
|
||||
// the agent was just created in this panel and the history endpoint
|
||||
// would 404. Otherwise (including the read-only suppression path) load
|
||||
// whatever history exists so the panel shows real content instead of a
|
||||
// misleading "describe your agent" empty state.
|
||||
if (consumesSeed) {
|
||||
return;
|
||||
}
|
||||
void loadHistory();
|
||||
|
|
|
|||
|
|
@ -2,16 +2,23 @@ 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';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
|
||||
type AgentPermissionKey = 'create' | 'update' | 'delete' | 'publish' | 'unpublish';
|
||||
|
||||
export type AgentPermissions = Record<`can${Capitalize<AgentPermissionKey>}`, ComputedRef<boolean>>;
|
||||
|
||||
// All five permissions gate mutations, so we additionally block them whenever
|
||||
// source control puts the instance in a read-only branch. Agents themselves
|
||||
// aren't tracked by source control, but `branchReadOnly` doubles as the
|
||||
// instance-wide "no writes" signal — matching how other resource views
|
||||
// (workflows, credentials, data tables) combine scopes with this flag.
|
||||
export function useAgentPermissions(
|
||||
projectId: MaybeRefOrGetter<string | undefined>,
|
||||
): AgentPermissions {
|
||||
const projectsStore = useProjectsStore();
|
||||
const usersStore = useUsersStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const projectScopes = computed(
|
||||
() =>
|
||||
|
|
@ -22,9 +29,12 @@ export function useAgentPermissions(
|
|||
const globalScopes = computed(
|
||||
() => getResourcePermissions(usersStore.currentUser?.globalScopes).agent,
|
||||
);
|
||||
const isReadOnly = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||
|
||||
const pick = (key: AgentPermissionKey): ComputedRef<boolean> =>
|
||||
computed(() => Boolean(globalScopes.value[key] ?? projectScopes.value[key]));
|
||||
computed(
|
||||
() => !isReadOnly.value && Boolean(globalScopes.value[key] ?? projectScopes.value[key]),
|
||||
);
|
||||
|
||||
return {
|
||||
canCreate: pick('create'),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import InsightsSummary from '@/features/execution/insights/components/InsightsSu
|
|||
import { useInsightsStore } from '@/features/execution/insights/insights.store';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { listAgents } from '../composables/useAgentApi';
|
||||
import { useAgentPermissions } from '../composables/useAgentPermissions';
|
||||
import type { AgentResource } from '../types';
|
||||
import { AGENT_BUILDER_VIEW, NEW_AGENT_VIEW } from '../constants';
|
||||
import AgentCard from '../components/AgentCard.vue';
|
||||
|
|
@ -27,15 +26,10 @@ const rootStore = useRootStore();
|
|||
const projectsStore = useProjectsStore();
|
||||
const insightsStore = useInsightsStore();
|
||||
const projectPages = useProjectPages();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
|
||||
const canCreateAgent = computed(
|
||||
() =>
|
||||
!sourceControlStore.preferences.branchReadOnly &&
|
||||
!!getResourcePermissions(homeProject.value?.scopes)?.agent?.create,
|
||||
);
|
||||
const { canCreate: canCreateAgent } = useAgentPermissions(() => homeProject.value?.id);
|
||||
|
||||
const allAgents = ref<AgentResource[]>([]);
|
||||
const loading = ref(true);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import * as router from 'vue-router';
|
|||
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
|
||||
import ProjectHeader from './ProjectHeader.vue';
|
||||
import { useProjectsStore } from '../projects.store';
|
||||
import type { Project } from '../projects.types';
|
||||
import type { Project, ProjectListItem } from '../projects.types';
|
||||
import { ProjectTypes } from '../projects.types';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
|
@ -306,9 +306,11 @@ describe('ProjectHeader', () => {
|
|||
describe('new agent telemetry', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
|
||||
projectsStore.currentProject = createTestProject({
|
||||
const project = createTestProject({
|
||||
scopes: ['workflow:create', 'agent:create'],
|
||||
});
|
||||
projectsStore.currentProject = project;
|
||||
projectsStore.myProjects = [project] as unknown as ProjectListItem[];
|
||||
});
|
||||
|
||||
it('tracks source=button when the agent main button is clicked', async () => {
|
||||
|
|
@ -639,6 +641,7 @@ describe('ProjectHeader', () => {
|
|||
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
|
||||
const project = createTestProject({ scopes: ['agent:create'] });
|
||||
projectsStore.currentProject = project;
|
||||
projectsStore.myProjects = [project] as unknown as ProjectListItem[];
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
|
||||
|
||||
|
|
@ -650,6 +653,7 @@ describe('ProjectHeader', () => {
|
|||
settingsStore.isModuleActive = vi.fn().mockImplementation((mod) => mod === 'agents');
|
||||
const project = createTestProject({ scopes: [] });
|
||||
projectsStore.currentProject = project;
|
||||
projectsStore.myProjects = [project] as unknown as ProjectListItem[];
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { mainButton: 'agent' } });
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N
|
|||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
|
||||
import { NEW_AGENT_VIEW } from '@/features/agents/constants';
|
||||
import { useAgentPermissions } from '@/features/agents/composables/useAgentPermissions';
|
||||
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
|
||||
|
||||
import { N8nButton, N8nHeading, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
|
|
@ -80,6 +81,8 @@ const headerIcon = computed((): IconOrEmoji => {
|
|||
|
||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
|
||||
const { canCreate: canCreateAgent } = useAgentPermissions(() => homeProject.value?.id);
|
||||
|
||||
const isPersonalProject = computed(() => {
|
||||
return homeProject.value?.type === ProjectTypes.Personal;
|
||||
});
|
||||
|
|
@ -200,9 +203,7 @@ const createAgentButton = computed(() => ({
|
|||
value: ACTION_TYPES.AGENT,
|
||||
label: i18n.baseText('projects.header.create.agent'),
|
||||
size: 'mini' as const,
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).agent.create,
|
||||
disabled: !canCreateAgent.value,
|
||||
}));
|
||||
|
||||
const selectedMainButtonType = computed(() => {
|
||||
|
|
@ -297,9 +298,7 @@ const menu = computed(() => {
|
|||
items.push({
|
||||
value: ACTION_TYPES.AGENT,
|
||||
label: i18n.baseText('projects.header.create.agent'),
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).agent.create,
|
||||
disabled: !canCreateAgent.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user