From dec7c712c7b9f565b3ba0176c3ca65ec7dfdea04 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Tue, 22 Sep 2026 14:51:46 +0000 Subject: [PATCH] fix(core): Gate the AI Assistant project list on the team-projects license (#39235) Co-authored-by: Claude Opus 5 (1M context) --- .../instance-ai.adapter.service.test.ts | 60 +++++++++++++++++++ .../instance-ai.adapter.service.ts | 29 +++++++-- .../ai/instanceAi/InstanceAiEmptyView.vue | 12 +++- .../__tests__/InstanceAiEmptyView.test.ts | 15 +++++ 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index aaaad7c8d2a..bebce8c8556 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -2286,6 +2286,7 @@ describe('createDataTableAdapter', () => { function createWorkflowAdapterForTests(overrides?: { namedVersionsLicensed?: boolean; foldersLicensed?: boolean; + teamProjectsLicensed?: boolean; branchReadOnly?: boolean; sharingEnabled?: boolean; folderExploration?: boolean; @@ -2371,6 +2372,7 @@ function createWorkflowAdapterForTests(overrides?: { }; const mockProjectService = { getAccessibleProjects: vi.fn().mockResolvedValue([{ id: 'team-project-id' }, { id: 'p2' }]), + getPersonalProject: vi.fn().mockResolvedValue({ id: 'personal-project-id' }), }; const mockLogger = { error: vi.fn(), @@ -2454,6 +2456,11 @@ function createWorkflowAdapterForTests(overrides?: { : (mockFolderFinderService as unknown as ConstructorParameters< typeof InstanceAiAdapterService >[41]), + undefined, + undefined, + { + isTeamProjectsLicensed: vi.fn().mockReturnValue(overrides?.teamProjectsLicensed ?? true), + } as unknown as ConstructorParameters[44], ); const boundProjectId = @@ -4253,6 +4260,49 @@ describe('license-gated features', () => { }); }); + describe('projects (quota:maxTeamProjects)', () => { + // An admin reads every project on the instance, peer personal ones included. + const accessibleProjects = [ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + { id: 'team-project-id', name: 'Team', type: 'team' }, + { id: 'p2', name: 'Other team', type: 'team' }, + { id: 'peer-personal-id', name: 'Peer', type: 'personal' }, + ]; + + it('lists every accessible project when team projects are licensed', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: true, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual(accessibleProjects); + }); + + it('lists only the personal and the bound project when team projects are not licensed', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: false, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual([ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + { id: 'team-project-id', name: 'Team', type: 'team' }, + ]); + }); + + it('lists only the personal project when team projects are not licensed and no project is bound', async () => { + const { context, mockProjectService } = createWorkflowAdapterForTests({ + teamProjectsLicensed: false, + projectId: null, + }); + mockProjectService.getAccessibleProjects.mockResolvedValue(accessibleProjects); + + await expect(context.workspaceService!.listProjects()).resolves.toEqual([ + { id: 'personal-project-id', name: 'Personal', type: 'personal' }, + ]); + }); + }); + describe('licenseHints', () => { it('includes hints for unlicensed features', () => { const { context } = createWorkflowAdapterForTests({ @@ -4288,6 +4338,16 @@ describe('license-gated features', () => { expect.arrayContaining([expect.stringContaining('Named workflow versions')]), ); }); + + it('includes a hint when team projects are not licensed', () => { + const { context } = createWorkflowAdapterForTests({ + namedVersionsLicensed: true, + foldersLicensed: true, + teamProjectsLicensed: false, + }); + + expect(context.licenseHints).toEqual([expect.stringContaining('Team projects')]); + }); }); }); diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index c0b47a9a948..4eba352f4cc 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -17,7 +17,7 @@ import { INSTANCE_AI_PROGRESSIVE_BUILDING_ENABLED_VARIANT, } from '@n8n/api-types'; import type { AiGatewayConfigDto } from '@n8n/api-types'; -import { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { OutboundHttp } from '@n8n/backend-network'; import { GlobalConfig } from '@n8n/config'; import { Time } from '@n8n/constants'; @@ -410,6 +410,9 @@ export class InstanceAiAdapterService { private readonly folderFinderService?: FolderFinderService, private readonly instanceContext?: InstanceContextService, private readonly executeNodeService?: ExecuteNodeService, + // Optional for the same positional-construction reason as above. + // See `teamProjectsLicensed()` for the absent case. + private readonly licenseState?: LicenseState, ) { this.logger = logger.scoped('instance-ai'); this.allowSendingParameterValues = globalConfig.ai.allowSendingParameterValues; @@ -517,7 +520,7 @@ export class InstanceAiAdapterService { ? { activityService: this.createActivityAdapter(user, projectId) } : {}), webResearchService: this.createWebResearchAdapter(user, searchProxyConfig), - workspaceService: this.createWorkspaceAdapter(user), + workspaceService: this.createWorkspaceAdapter(user, projectId), templatesService: this.getTemplatesService(), workflowTemplateService: this.createWorkflowTemplateAdapter(), licenseHints: this.buildLicenseHints(), @@ -786,6 +789,11 @@ export class InstanceAiAdapterService { }; } + /** Team projects are a quota, not a feature flag, so the read goes through `LicenseState`. */ + private teamProjectsLicensed(): boolean { + return this.licenseState?.isTeamProjectsLicensed() ?? true; + } + private buildLicenseHints(): string[] { const hints: string[] = []; if (!this.license.isLicensed('feat:namedVersions')) { @@ -798,6 +806,11 @@ export class InstanceAiAdapterService { '**Folders** — organizing workflows into folders (list-folders, create-folder, delete-folder, move-workflow-to-folder) is available on registered Community Edition or paid plans.', ); } + if (!this.teamProjectsLicensed()) { + hints.push( + '**Team projects** — this instance has no team-project license. `list-projects` returns only the user personal project and the project of this conversation, even when the instance holds more. Do not offer to create or move resources into another project. Team projects need a plan upgrade.', + ); + } return hints; } @@ -3770,7 +3783,7 @@ export class InstanceAiAdapterService { }; } - private createWorkspaceAdapter(user: User): InstanceAiWorkspaceService { + private createWorkspaceAdapter(user: User, boundProjectId?: string): InstanceAiWorkspaceService { const { projectService, folderService, @@ -3783,6 +3796,7 @@ export class InstanceAiAdapterService { } = this; const assertNotReadOnly = (resource: string) => this.assertInstanceNotReadOnly(resource); const { assertProjectScope } = this.createProjectScopeHelpers(user); + const teamProjectsLicensed = this.teamProjectsLicensed(); const adapter: InstanceAiWorkspaceService = { async getProject(projectId: string): Promise { @@ -3793,11 +3807,18 @@ export class InstanceAiAdapterService { async listProjects(): Promise { const projects = await projectService.getAccessibleProjects(user); - return projects.map((p) => ({ + const summaries = projects.map((p) => ({ id: p.id, name: p.name, type: p.type, })); + if (teamProjectsLicensed) return summaries; + // An instance that loses its team-project license keeps its projects, and + // an admin still reads all of them. The user cannot work in those + // projects, so list only their own personal project and the project this + // conversation is bound to. + const personalProjectId = (await projectService.getPersonalProject(user))?.id; + return summaries.filter((p) => p.id === personalProjectId || p.id === boundProjectId); }, ...(this.license.isLicensed('feat:folders') diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue index c1a7b02e79c..79b8dd06c6e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue @@ -125,6 +125,12 @@ function resolveLaunchSource(): InstanceAiThreadSource { } const selectedProject = ref(resolveInitialProjectId()); +// An instance that loses its team-project license keeps its projects, but the +// user cannot work in them. Hide the picker then, the same way the sidebar +// project list hides itself. +const canSelectProject = computed( + () => projectsStore.isTeamProjectFeatureEnabled && projectsStore.myProjects.length > 1, +); const settingsStore = useInstanceAiSettingsStore(); const { showCreditWarning, quotaLocked } = storeToRefs(store); const rootStore = useRootStore(); @@ -667,7 +673,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) { @submit="handleSubmit" @content-change="composerHasContent = $event" > -