mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(core): Gate the AI Assistant project list on the team-projects license (#39235)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
22d18a7bc9
commit
dec7c712c7
|
|
@ -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<typeof InstanceAiAdapterService>[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')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ProjectSummary | null> {
|
||||
|
|
@ -3793,11 +3807,18 @@ export class InstanceAiAdapterService {
|
|||
|
||||
async listProjects(): Promise<ProjectSummary[]> {
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<template v-if="projectsStore.myProjects.length > 1" #footer>
|
||||
<template v-if="canSelectProject" #footer>
|
||||
<div :class="$style.inputFooter">
|
||||
<ProjectSelect v-model="selectedProject" />
|
||||
</div>
|
||||
|
|
@ -711,7 +717,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
|
|||
@submit="handleSubmit"
|
||||
@content-change="composerHasContent = $event"
|
||||
>
|
||||
<template v-if="projectsStore.myProjects.length > 1" #footer>
|
||||
<template v-if="canSelectProject" #footer>
|
||||
<div :class="$style.inputFooter" data-test-id="instance-ai-split-project-select">
|
||||
<ProjectSelect v-model="selectedProject" />
|
||||
</div>
|
||||
|
|
@ -748,7 +754,7 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
|
|||
@workflow-preview="handleWorkflowPreview"
|
||||
@content-change="composerHasContent = $event"
|
||||
>
|
||||
<template v-if="projectsStore.myProjects.length > 1" #footer>
|
||||
<template v-if="canSelectProject" #footer>
|
||||
<div :class="$style.inputFooter">
|
||||
<ProjectSelect v-model="selectedProject" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -452,6 +452,7 @@ describe('InstanceAiEmptyView', () => {
|
|||
store = mockedStore(useInstanceAiStore);
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = { id: PERSONAL_PROJECT_ID } as Project;
|
||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||
thread = {
|
||||
id: 'thread-placeholder',
|
||||
isStreaming: false,
|
||||
|
|
@ -816,6 +817,20 @@ describe('InstanceAiEmptyView', () => {
|
|||
expect(getByTestId('instance-ai-split-project-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the project selector in the split layout when team projects are not licensed', () => {
|
||||
experimentMocks.splitBelowInputVariant.value = true;
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.isTeamProjectFeatureEnabled = false;
|
||||
projectsStore.myProjects = [
|
||||
{ id: PERSONAL_PROJECT_ID, type: 'personal' },
|
||||
{ id: 'team-project', type: 'team', name: 'Team project' },
|
||||
] as ProjectListItem[];
|
||||
|
||||
const { queryByTestId } = renderView();
|
||||
|
||||
expect(queryByTestId('instance-ai-split-project-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the project selector in the split layout when only the personal project exists', () => {
|
||||
experimentMocks.splitBelowInputVariant.value = true;
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user