mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(core): Turn Chat Hub off by default for instances not using it (#33136)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9977e7757a
commit
c108dbf64a
|
|
@ -595,6 +595,10 @@ export class UpdateChatSettingsRequest extends Z.class({
|
|||
payload: chatProviderSettingsSchema,
|
||||
}) {}
|
||||
|
||||
export class UpdateChatEnabledRequest extends Z.class({
|
||||
enabled: z.boolean(),
|
||||
}) {}
|
||||
|
||||
export class ChatHubSemanticSearchSettings extends Z.class({
|
||||
vectorStore: z.object({
|
||||
provider: chatHubVectorStoreProviderSchema,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export {
|
|||
suggestedPromptsSchema,
|
||||
type MessageChunk,
|
||||
UpdateChatSettingsRequest,
|
||||
UpdateChatEnabledRequest,
|
||||
ChatHubSemanticSearchSettings,
|
||||
type ChatProviderSettingsDto,
|
||||
type ChatSendMessageResponse,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
const CHAT_ENABLED_KEY = 'chat.access.enabled';
|
||||
|
||||
/**
|
||||
* Turns Chat Hub off by default, keeping it on only for installs that have
|
||||
* already used it.
|
||||
*
|
||||
* Stores an explicit `chat.access.enabled` value for every install, so the
|
||||
* disabled-by-default state is recorded concretely and is auditable rather
|
||||
* than inferred from a missing row.
|
||||
*
|
||||
* - row already present -> leave untouched (deliberate admin choice, true or false)
|
||||
* - any chat_hub_sessions -> store 'true' (Chat Hub is in use)
|
||||
* - otherwise -> store 'false' (disabled)
|
||||
*
|
||||
* The settings service treats a missing row as disabled too, so absence is a
|
||||
* safe fallback; this migration just removes the ambiguity for existing
|
||||
* installs.
|
||||
*
|
||||
* The value is stored as the bare string `'true'`/`'false'` to match how the
|
||||
* settings service reads (`row.value === 'true'`) and writes it.
|
||||
*
|
||||
* `down()` is intentionally a no-op: the stored value is left in place
|
||||
* and it is picked up again if the instance is upgraded later.
|
||||
*/
|
||||
export class SetChatHubEnabledFromUsage1784000000038 implements ReversibleMigration {
|
||||
async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
|
||||
const settingsTable = escape.tableName('settings');
|
||||
const sessionsTable = escape.tableName('chat_hub_sessions');
|
||||
const keyCol = escape.columnName('key');
|
||||
const valueCol = escape.columnName('value');
|
||||
const loadCol = escape.columnName('loadOnStartup');
|
||||
|
||||
const existing: Array<{ value: string }> = await runQuery(
|
||||
`SELECT ${valueCol} AS value FROM ${settingsTable} WHERE ${keyCol} = :key LIMIT 1;`,
|
||||
{ key: CHAT_ENABLED_KEY },
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.info(
|
||||
`[${migrationName}] Chat Hub enabled setting already present, leaving it untouched`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const usage: Array<{ used: number }> = await runQuery(
|
||||
`SELECT 1 AS used FROM ${sessionsTable} LIMIT 1;`,
|
||||
);
|
||||
const enabled = usage.length > 0;
|
||||
|
||||
await runQuery(
|
||||
`INSERT INTO ${settingsTable} (${keyCol}, ${valueCol}, ${loadCol}) VALUES (:key, :value, true);`,
|
||||
{ key: CHAT_ENABLED_KEY, value: enabled ? 'true' : 'false' },
|
||||
);
|
||||
|
||||
logger.info(`[${migrationName}] Persisted Chat Hub enabled=${enabled} based on existing usage`);
|
||||
}
|
||||
|
||||
// No-op: the stored value is harmless and is reused on a later upgrade.
|
||||
async down() {}
|
||||
}
|
||||
|
|
@ -211,6 +211,7 @@ import { AllowAzureStoredAt1784000000034 } from '../common/1784000000034-AllowAz
|
|||
import { AddUniqueAgentFileNames1784000000035 } from '../common/1784000000035-AddUniqueAgentFileNames';
|
||||
import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000000036-CreateInstanceAiThreadGrantTable';
|
||||
import { DropAgentDescriptionFromAgents1784000000037 } from '../common/1784000000037-DropAgentDescriptionFromAgents';
|
||||
import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
|
|
@ -427,4 +428,5 @@ export const postgresMigrations: Migration[] = [
|
|||
AddUniqueAgentFileNames1784000000035,
|
||||
CreateInstanceAiThreadGrantTable1784000000036,
|
||||
DropAgentDescriptionFromAgents1784000000037,
|
||||
SetChatHubEnabledFromUsage1784000000038,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ import { AddJsonSizeBytesAndWorkflowVersionIdToExecutionEntity1784000000029 } fr
|
|||
import { AddBinaryDataSizeBytesToExecutionEntity1784000000033 } from '../common/1784000000033-AddBinaryDataSizeBytesToExecutionEntity';
|
||||
import { AddUniqueAgentFileNames1784000000035 } from '../common/1784000000035-AddUniqueAgentFileNames';
|
||||
import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000000036-CreateInstanceAiThreadGrantTable';
|
||||
import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
|
|
@ -411,6 +412,7 @@ const sqliteMigrations: Migration[] = [
|
|||
AddUniqueAgentFileNames1784000000035,
|
||||
CreateInstanceAiThreadGrantTable1784000000036,
|
||||
DropAgentDescriptionFromAgents1784000000037,
|
||||
SetChatHubEnabledFromUsage1784000000038,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { ModuleSettings } from '@n8n/decorators';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { sendErrorResponse } from '@/response-helper';
|
||||
|
||||
import { ChatHubController } from '../chat-hub.controller';
|
||||
|
||||
jest.mock('@/response-helper');
|
||||
|
||||
describe('ChatHubController', () => {
|
||||
const settingsMap = new Map<string, ModuleSettings>();
|
||||
const moduleRegistry = { settings: settingsMap } as unknown as ModuleRegistry;
|
||||
const controller = new ChatHubController(mock(), mock(), mock(), mock(), mock(), moduleRegistry);
|
||||
|
||||
const req = mock<Request>();
|
||||
const res = mock<Response>();
|
||||
let next: NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
settingsMap.clear();
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
describe('checkChatEnabled middleware', () => {
|
||||
it('should reject with a ForbiddenError when Chat Hub is disabled', () => {
|
||||
settingsMap.set('chat-hub', { enabled: false });
|
||||
|
||||
controller.checkChatEnabled(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(sendErrorResponse).toHaveBeenCalledWith(res, expect.any(ForbiddenError));
|
||||
});
|
||||
|
||||
it('should pass through when Chat Hub is enabled', () => {
|
||||
settingsMap.set('chat-hub', { enabled: true });
|
||||
|
||||
controller.checkChatEnabled(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(sendErrorResponse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject when no cached setting exists (fail closed)', () => {
|
||||
controller.checkChatEnabled(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(sendErrorResponse).toHaveBeenCalledWith(res, expect.any(ForbiddenError));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { ModuleRegistry, Logger } from '@n8n/backend-common';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { ChatHubSettingsController } from '../chat-hub.settings.controller';
|
||||
import type { ChatHubSettingsService } from '../chat-hub.settings.service';
|
||||
|
||||
describe('ChatHubSettingsController', () => {
|
||||
const settings = mock<ChatHubSettingsService>();
|
||||
const moduleRegistry = mock<ModuleRegistry>();
|
||||
const logger = mock<Logger>();
|
||||
logger.scoped.mockReturnValue(logger);
|
||||
|
||||
const controller = new ChatHubSettingsController(settings, logger, moduleRegistry);
|
||||
|
||||
const req = mock<AuthenticatedRequest>();
|
||||
const res = mock<Response>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('should persist the flag and refresh module settings', async () => {
|
||||
const result = await controller.setEnabled(req, res, { enabled: false });
|
||||
|
||||
expect(settings.setEnabled).toHaveBeenCalledWith(false);
|
||||
expect(moduleRegistry.refreshModuleSettings).toHaveBeenCalledWith('chat-hub');
|
||||
expect(result).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('should still resolve when the module registry refresh fails', async () => {
|
||||
moduleRegistry.refreshModuleSettings.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const result = await controller.setEnabled(req, res, { enabled: true });
|
||||
|
||||
expect(settings.setEnabled).toHaveBeenCalledWith(true);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(result).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('re-enables chat regardless of the current disabled state', async () => {
|
||||
// The disabled-state gate lives on ChatHubController, not here, so this
|
||||
// endpoint must stay reachable to turn the feature back on.
|
||||
const result = await controller.setEnabled(req, res, { enabled: true });
|
||||
|
||||
expect(settings.setEnabled).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
} from '@n8n/api-types';
|
||||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import {
|
||||
RestController,
|
||||
|
|
@ -35,23 +36,26 @@ import {
|
|||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
Middleware,
|
||||
} from '@n8n/decorators';
|
||||
import { Container } from '@n8n/di';
|
||||
import { sanitizeFilename } from '@n8n/utils';
|
||||
import type { Response } from 'express';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { sendErrorResponse } from '@/response-helper';
|
||||
|
||||
import { ChatHubAgentService } from './chat-hub-agent.service';
|
||||
import { ChatHubUploadMiddleware } from './chat-hub-upload.middleware';
|
||||
import { ChatHubToolService } from './chat-hub-tool.service';
|
||||
import { extractAuthenticationMetadata } from './chat-hub-extractor';
|
||||
import { ChatHubToolService } from './chat-hub-tool.service';
|
||||
import { ChatHubUploadMiddleware } from './chat-hub-upload.middleware';
|
||||
import { ChatHubAttachmentService } from './chat-hub.attachment.service';
|
||||
import { ChatHubModelsService } from './chat-hub.models.service';
|
||||
import { ChatHubService } from './chat-hub.service';
|
||||
import { ChatModelsRequestDto } from './dto/chat-models-request.dto';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
const chatHubUploadMiddleware = Container.get(ChatHubUploadMiddleware);
|
||||
|
||||
@RestController('/chat')
|
||||
|
|
@ -62,8 +66,22 @@ export class ChatHubController {
|
|||
private readonly chatAgentService: ChatHubAgentService,
|
||||
private readonly chatToolService: ChatHubToolService,
|
||||
private readonly chatAttachmentService: ChatHubAttachmentService,
|
||||
private readonly moduleRegistry: ModuleRegistry,
|
||||
) {}
|
||||
|
||||
@Middleware()
|
||||
checkChatEnabled(_req: Request, res: Response, next: NextFunction) {
|
||||
// Fail closed: block unless Chat Hub is explicitly enabled. Disabled is the
|
||||
// default, and existing installs with usage get an explicit enabled value
|
||||
// via migration, so this only locks out the genuinely-off (or unset) state.
|
||||
// Reads the cached module settings (kept fresh by refreshModuleSettings on toggle).
|
||||
if (this.moduleRegistry.settings.get('chat-hub')?.enabled !== true) {
|
||||
sendErrorResponse(res, new ForbiddenError('Chat Hub is disabled'));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
@Post('/models')
|
||||
@GlobalScope('chatHub:message')
|
||||
async getModels(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { ModuleRegistry, Logger } from '@n8n/backend-common';
|
||||
import { type AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Post, Put, RestController, GlobalScope, Param } from '@n8n/decorators';
|
||||
|
||||
import { ChatHubSettingsService } from './chat-hub.settings.service';
|
||||
import {
|
||||
ChatHubLLMProvider,
|
||||
chatHubLLMProviderSchema,
|
||||
UpdateChatSettingsRequest,
|
||||
UpdateChatEnabledRequest,
|
||||
ChatHubSemanticSearchSettings,
|
||||
} from '@n8n/api-types';
|
||||
import { ModuleRegistry, Logger } from '@n8n/backend-common';
|
||||
import { type AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Get, Post, Put, RestController, GlobalScope, Param } from '@n8n/decorators';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
||||
import { ChatHubSettingsService } from './chat-hub.settings.service';
|
||||
|
||||
@RestController('/chat')
|
||||
export class ChatHubSettingsController {
|
||||
constructor(
|
||||
|
|
@ -21,6 +23,17 @@ export class ChatHubSettingsController {
|
|||
this.logger = this.logger.scoped('chat-hub');
|
||||
}
|
||||
|
||||
/** Refresh the cached module settings so changes take effect without a restart. */
|
||||
private async syncModuleSettings() {
|
||||
try {
|
||||
await this.moduleRegistry.refreshModuleSettings('chat-hub');
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to sync chat settings to module registry', {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/settings')
|
||||
@GlobalScope('chatHub:manage')
|
||||
async getSettings(_req: AuthenticatedRequest, _res: Response) {
|
||||
|
|
@ -52,17 +65,24 @@ export class ChatHubSettingsController {
|
|||
) {
|
||||
const { payload } = body;
|
||||
await this.settings.setProviderSettings(payload.provider, payload);
|
||||
try {
|
||||
await this.moduleRegistry.refreshModuleSettings('chat-hub');
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to sync chat settings to module registry', {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
await this.syncModuleSettings();
|
||||
|
||||
return await this.settings.getProviderSettings(payload.provider);
|
||||
}
|
||||
|
||||
@Put('/enabled')
|
||||
@GlobalScope('chatHub:manage')
|
||||
async setEnabled(
|
||||
_req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Body body: UpdateChatEnabledRequest,
|
||||
) {
|
||||
await this.settings.setEnabled(body.enabled);
|
||||
await this.syncModuleSettings();
|
||||
|
||||
return { enabled: body.enabled };
|
||||
}
|
||||
|
||||
@Put('/semantic-search')
|
||||
@GlobalScope('chatHub:manage')
|
||||
async setVectorStoreCredential(
|
||||
|
|
@ -71,12 +91,6 @@ export class ChatHubSettingsController {
|
|||
@Body body: ChatHubSemanticSearchSettings,
|
||||
) {
|
||||
await this.settings.setSemanticSearchSettings(body);
|
||||
try {
|
||||
await this.moduleRegistry.refreshModuleSettings('chat-hub');
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to sync chat settings to module registry', {
|
||||
cause: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
await this.syncModuleSettings();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ export class ChatHubSettingsService {
|
|||
|
||||
async getEnabled(): Promise<boolean> {
|
||||
const row = await this.settingsRepository.findByKey(CHAT_ENABLED_KEY);
|
||||
// Allowed by default
|
||||
if (!row) return true;
|
||||
// Disabled by default. The deprecation migration persists an explicit
|
||||
// value for every existing install, so a missing row is only the
|
||||
// safety-net case (e.g. deleted row) and is treated as disabled.
|
||||
if (!row) return false;
|
||||
return row.value === 'true';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import {
|
||||
createTestMigrationContext,
|
||||
initDbUpToMigration,
|
||||
runSingleMigration,
|
||||
type TestMigrationContext,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { DbConnection } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { DataSource } from '@n8n/typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const MIGRATION_NAME = 'SetChatHubEnabledFromUsage1784000000038';
|
||||
const CHAT_ENABLED_KEY = 'chat.access.enabled';
|
||||
|
||||
describe('SetChatHubEnabledFromUsage Migration', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
dataSource = Container.get(DataSource);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
await context.queryRunner.clearDatabase();
|
||||
await context.queryRunner.release();
|
||||
await initDbUpToMigration(MIGRATION_NAME);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.close();
|
||||
});
|
||||
|
||||
async function insertUser(context: TestMigrationContext, id: string): Promise<void> {
|
||||
const tableName = context.escape.tableName('user');
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${tableName} ("id", "email", "firstName", "lastName", "password", "roleSlug", "createdAt", "updatedAt") VALUES (:id, :email, :firstName, :lastName, :password, :roleSlug, :createdAt, :updatedAt)`,
|
||||
{
|
||||
id,
|
||||
email: `${id}@test.com`,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
password: 'hashed',
|
||||
roleSlug: 'global:member',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function insertSession(context: TestMigrationContext, ownerId: string): Promise<void> {
|
||||
const tableName = context.escape.tableName('chat_hub_sessions');
|
||||
const now = new Date();
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${tableName} ("id", "title", "ownerId", "lastMessageAt", "createdAt", "updatedAt") VALUES (:id, :title, :ownerId, :lastMessageAt, :createdAt, :updatedAt)`,
|
||||
{
|
||||
id: randomUUID(),
|
||||
title: 'Test Session',
|
||||
ownerId,
|
||||
lastMessageAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function insertSetting(context: TestMigrationContext, value: string): Promise<void> {
|
||||
const tableName = context.escape.tableName('settings');
|
||||
const keyCol = context.escape.columnName('key');
|
||||
const valueCol = context.escape.columnName('value');
|
||||
const loadCol = context.escape.columnName('loadOnStartup');
|
||||
await context.runQuery(
|
||||
`INSERT INTO ${tableName} (${keyCol}, ${valueCol}, ${loadCol}) VALUES (:key, :value, :load)`,
|
||||
{ key: CHAT_ENABLED_KEY, value, load: true },
|
||||
);
|
||||
}
|
||||
|
||||
async function getStoredValue(): Promise<string | undefined> {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
const tableName = context.escape.tableName('settings');
|
||||
const keyCol = context.escape.columnName('key');
|
||||
const valueCol = context.escape.columnName('value');
|
||||
const rows: Array<{ value: string }> = await context.runQuery(
|
||||
`SELECT ${valueCol} AS value FROM ${tableName} WHERE ${keyCol} = :key`,
|
||||
{ key: CHAT_ENABLED_KEY },
|
||||
);
|
||||
await context.queryRunner.release();
|
||||
return rows[0]?.value;
|
||||
}
|
||||
|
||||
it("writes 'true' when Chat Hub usage exists and no setting is stored", async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
const userId = randomUUID();
|
||||
await insertUser(context, userId);
|
||||
await insertSession(context, userId);
|
||||
await context.queryRunner.release();
|
||||
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
expect(await getStoredValue()).toBe('true');
|
||||
});
|
||||
|
||||
it("writes an explicit 'false' when there is no usage", async () => {
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
expect(await getStoredValue()).toBe('false');
|
||||
});
|
||||
|
||||
it("does not overwrite an existing 'false' setting even when usage exists", async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
const userId = randomUUID();
|
||||
await insertUser(context, userId);
|
||||
await insertSession(context, userId);
|
||||
await insertSetting(context, 'false');
|
||||
await context.queryRunner.release();
|
||||
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
expect(await getStoredValue()).toBe('false');
|
||||
});
|
||||
|
||||
it("does not duplicate an existing 'true' setting", async () => {
|
||||
const context = createTestMigrationContext(dataSource);
|
||||
const userId = randomUUID();
|
||||
await insertUser(context, userId);
|
||||
await insertSession(context, userId);
|
||||
await insertSetting(context, 'true');
|
||||
await context.queryRunner.release();
|
||||
|
||||
await runSingleMigration(MIGRATION_NAME);
|
||||
dataSource = Container.get(DataSource);
|
||||
|
||||
expect(await getStoredValue()).toBe('true');
|
||||
});
|
||||
});
|
||||
|
|
@ -3192,6 +3192,12 @@
|
|||
"experiments.surfaceMcpToNewCloudUsers.emptyState.tile.badge.enabled": "Enabled",
|
||||
"experiments.surfaceMcpToNewCloudUsers.emptyState.reminder": "You can enable this later in Settings > MCP.",
|
||||
"settings.chatHub": "Chat",
|
||||
"settings.chatHub.enabled.label": "Enable Chat",
|
||||
"settings.chatHub.enabled.description": "When disabled, Chat is hidden across the app and its API endpoints are turned off. You can re-enable it here at any time.",
|
||||
"settings.chatHub.enabled.disabled.tooltip": "Only instance admins and owners can enable or disable Chat.",
|
||||
"settings.chatHub.toggled.success": "Chat settings updated",
|
||||
"settings.chatHub.toggled.error": "Error updating Chat settings",
|
||||
"settings.chatHub.disabled.notice": "Chat is currently disabled. Enable it above to configure providers.",
|
||||
"settings.chatHub.providers.fetching.error": "Error fetching chat provider settings",
|
||||
"settings.chatHub.providers.updated.success": "Chat provider settings updated",
|
||||
"settings.chatHub.providers.updated.error": "Error updating chat provider settings",
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||
const isDataTableFeatureEnabled = computed(() => isModuleActive('data-table'));
|
||||
|
||||
const isChatFeatureEnabled = computed(
|
||||
() => isModuleActive('chat-hub') && moduleSettings.value['chat-hub']?.enabled !== false,
|
||||
() => isModuleActive('chat-hub') && moduleSettings.value['chat-hub']?.enabled === true,
|
||||
);
|
||||
|
||||
const isOtelCustomSpanAttributesEnabled = computed(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createChatHubModuleSettings } from './__test__/data';
|
||||
import SettingsChatHubView from './SettingsChatHubView.vue';
|
||||
|
||||
const { settingsState, setChatEnabledMock } = vi.hoisted(() => ({
|
||||
settingsState: {
|
||||
enabled: true as boolean | undefined,
|
||||
isChatFeatureEnabled: true,
|
||||
},
|
||||
setChatEnabledMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/settings.store', () => ({
|
||||
useSettingsStore: () => ({
|
||||
get moduleSettings() {
|
||||
return { 'chat-hub': createChatHubModuleSettings({ enabled: settingsState.enabled }) };
|
||||
},
|
||||
get isChatFeatureEnabled() {
|
||||
return settingsState.isChatFeatureEnabled;
|
||||
},
|
||||
getModuleSettings: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./chat.store', () => ({
|
||||
useChatStore: () => ({
|
||||
settings: null,
|
||||
settingsLoading: false,
|
||||
fetchAllChatSettings: vi.fn().mockResolvedValue(undefined),
|
||||
setChatEnabled: setChatEnabledMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/settings/users/users.store', () => ({
|
||||
useUsersStore: () => ({ isInstanceOwner: true, isAdmin: true }),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
fetchAllCredentials: vi.fn().mockResolvedValue(undefined),
|
||||
fetchCredentialTypes: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/ui.store', () => ({
|
||||
useUIStore: () => ({ openModalWithData: vi.fn(), openNewCredential: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: vi.fn(), showMessage: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({ track: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: () => ({ set: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/posthog.store', () => ({
|
||||
usePostHog: () => ({ isVariantEnabled: vi.fn().mockReturnValue(false) }),
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(SettingsChatHubView, {
|
||||
global: {
|
||||
stubs: {
|
||||
ChatProvidersTable: { template: '<div data-test-id="chat-providers-table" />' },
|
||||
ChatSemanticSearchSettings: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('SettingsChatHubView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
settingsState.enabled = true;
|
||||
settingsState.isChatFeatureEnabled = true;
|
||||
});
|
||||
|
||||
it('renders the enable toggle and providers when Chat is enabled', () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('chat-hub-enabled-switch')).toBeInTheDocument();
|
||||
expect(getByTestId('chat-providers-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('chat-hub-disabled-notice')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the toggle and a disabled notice but no providers when Chat is disabled', () => {
|
||||
settingsState.enabled = false;
|
||||
settingsState.isChatFeatureEnabled = false;
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
|
||||
expect(getByTestId('chat-hub-enabled-switch')).toBeInTheDocument();
|
||||
expect(getByTestId('chat-hub-disabled-notice')).toBeInTheDocument();
|
||||
expect(queryByTestId('chat-providers-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setChatEnabled when the toggle is flipped', async () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('chat-hub-enabled-switch'));
|
||||
|
||||
expect(setChatEnabledMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('treats an unset enabled value as off (fail closed)', async () => {
|
||||
settingsState.enabled = undefined;
|
||||
settingsState.isChatFeatureEnabled = false;
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
// Toggle starts off, so flipping it turns Chat on.
|
||||
await userEvent.click(getByTestId('chat-hub-enabled-switch'));
|
||||
|
||||
expect(setChatEnabledMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
type ChatProviderSettingsDto,
|
||||
PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
} from '@n8n/api-types';
|
||||
import { N8nHeading } from '@n8n/design-system';
|
||||
import { N8nHeading, N8nSwitch, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useChatStore } from './chat.store';
|
||||
|
|
@ -43,6 +43,31 @@ const isOwner = computed(() => usersStore.isInstanceOwner);
|
|||
const isAdmin = computed(() => usersStore.isAdmin);
|
||||
const disabled = computed(() => !isOwner.value && !isAdmin.value);
|
||||
|
||||
const isChatEnabled = computed(() => settingsStore.moduleSettings['chat-hub']?.enabled === true);
|
||||
|
||||
async function onToggleEnabled(enabled: boolean) {
|
||||
try {
|
||||
await chatStore.setChatEnabled(enabled);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.chatHub.toggled.success'),
|
||||
type: 'success',
|
||||
});
|
||||
if (enabled) {
|
||||
await loadProviderSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.chatHub.toggled.error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderSettings() {
|
||||
await Promise.all([
|
||||
fetchSettings(),
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
]);
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
await chatStore.fetchAllChatSettings();
|
||||
|
|
@ -95,11 +120,7 @@ onMounted(async () => {
|
|||
if (!settingsStore.isChatFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
fetchSettings(),
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
]);
|
||||
await loadProviderSettings();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
|
|
@ -107,15 +128,44 @@ onMounted(async () => {
|
|||
<N8nHeading size="2xlarge">
|
||||
{{ i18n.baseText('settings.chatHub') }}
|
||||
</N8nHeading>
|
||||
<ChatProvidersTable
|
||||
data-test-id="chat-providers-table"
|
||||
:settings="chatStore.settings"
|
||||
:loading="chatStore.settingsLoading"
|
||||
:disabled="disabled"
|
||||
@edit-provider="onEditProvider"
|
||||
@refresh="onRefreshWorkflows"
|
||||
/>
|
||||
<ChatSemanticSearchSettings v-if="isSemanticSearchEnabled" />
|
||||
|
||||
<label :class="$style.enabledRow">
|
||||
<div :class="$style.enabledText">
|
||||
<N8nText bold>{{ i18n.baseText('settings.chatHub.enabled.label') }}</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.chatHub.enabled.description') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<N8nTooltip
|
||||
:content="i18n.baseText('settings.chatHub.enabled.disabled.tooltip')"
|
||||
:disabled="!disabled"
|
||||
placement="top"
|
||||
>
|
||||
<N8nSwitch
|
||||
data-test-id="chat-hub-enabled-switch"
|
||||
size="large"
|
||||
:model-value="isChatEnabled"
|
||||
:disabled="disabled"
|
||||
@update:model-value="onToggleEnabled"
|
||||
/>
|
||||
</N8nTooltip>
|
||||
</label>
|
||||
|
||||
<template v-if="settingsStore.isChatFeatureEnabled">
|
||||
<ChatProvidersTable
|
||||
data-test-id="chat-providers-table"
|
||||
:settings="chatStore.settings"
|
||||
:loading="chatStore.settingsLoading"
|
||||
:disabled="disabled"
|
||||
@edit-provider="onEditProvider"
|
||||
@refresh="onRefreshWorkflows"
|
||||
/>
|
||||
<ChatSemanticSearchSettings v-if="isSemanticSearchEnabled" />
|
||||
</template>
|
||||
<N8nText v-else color="text-light" data-test-id="chat-hub-disabled-notice">
|
||||
{{ i18n.baseText('settings.chatHub.disabled.notice') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -126,4 +176,18 @@ onMounted(async () => {
|
|||
gap: var(--spacing--2xl);
|
||||
padding-bottom: var(--spacing--2xl);
|
||||
}
|
||||
|
||||
.enabledRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing--lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.enabledText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -330,6 +330,15 @@ export const updateChatSettingsApi = async (
|
|||
});
|
||||
};
|
||||
|
||||
export const updateChatEnabledApi = async (
|
||||
context: IRestApiContext,
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> => {
|
||||
return await makeRestApiRequest<{ enabled: boolean }>(context, 'PUT', '/chat/enabled', {
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSemanticSearchSettingsApi = async (
|
||||
context: IRestApiContext,
|
||||
data: ChatHubSemanticSearchSettings,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { useChatStore } from './chat.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import * as chatApi from './chat.api';
|
||||
import type { ChatHubToolDto, ChatHubAgentDto, ChatHubSessionDto } from '@n8n/api-types';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
|
@ -424,3 +425,39 @@ describe('chat.store - tool methods', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat.store - setChatEnabled', () => {
|
||||
let store: ReturnType<typeof useChatStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
store = useChatStore();
|
||||
});
|
||||
|
||||
it('should call the api and refresh module settings when enabling', async () => {
|
||||
const updateSpy = vi
|
||||
.spyOn(chatApi, 'updateChatEnabledApi')
|
||||
.mockResolvedValue({ enabled: true });
|
||||
const settingsStore = useSettingsStore();
|
||||
const refreshSpy = vi.spyOn(settingsStore, 'getModuleSettings').mockResolvedValue();
|
||||
|
||||
await store.setChatEnabled(true);
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), true);
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call the api and refresh module settings when disabling', async () => {
|
||||
const updateSpy = vi
|
||||
.spyOn(chatApi, 'updateChatEnabledApi')
|
||||
.mockResolvedValue({ enabled: false });
|
||||
const settingsStore = useSettingsStore();
|
||||
const refreshSpy = vi.spyOn(settingsStore, 'getModuleSettings').mockResolvedValue();
|
||||
|
||||
await store.setChatEnabled(false);
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(expect.anything(), false);
|
||||
expect(refreshSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
fetchChatSettingsApi,
|
||||
fetchChatProviderSettingsApi,
|
||||
updateChatSettingsApi,
|
||||
updateChatEnabledApi,
|
||||
fetchToolsApi,
|
||||
createToolApi,
|
||||
updateToolApi,
|
||||
|
|
@ -1123,6 +1124,12 @@ export const useChatStore = defineStore(STORES.CHAT_HUB, () => {
|
|||
return providerSettings;
|
||||
}
|
||||
|
||||
async function setChatEnabled(enabled: boolean) {
|
||||
await updateChatEnabledApi(rootStore.restApiContext, enabled);
|
||||
// Refresh module settings so isChatFeatureEnabled recomputes app-wide.
|
||||
await settingsStore.getModuleSettings();
|
||||
}
|
||||
|
||||
async function updateProviderSettings(updated: ChatProviderSettingsDto) {
|
||||
if (!updated.enabled) {
|
||||
updated.allowedModels = [];
|
||||
|
|
@ -1585,6 +1592,7 @@ export const useChatStore = defineStore(STORES.CHAT_HUB, () => {
|
|||
fetchAllChatSettings,
|
||||
fetchProviderSettings,
|
||||
updateProviderSettings,
|
||||
setChatEnabled,
|
||||
semanticSearchReadiness,
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Project } from '@n8n/db';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { INSTANCE_OWNER_CREDENTIALS } from '../../../config/test-users';
|
||||
import { test as base, expect as baseExpect } from '../../../fixtures/base';
|
||||
import type { CredentialResponse } from '../../../services/credential-api-helper';
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ type ChatHubFixtures = {
|
|||
jinaCredential: CredentialResponse;
|
||||
jinaApiKey: string;
|
||||
chatHubProxySetup: undefined;
|
||||
chatHubEnabled: undefined;
|
||||
agentWorkflow: IWorkflowBase;
|
||||
};
|
||||
|
||||
|
|
@ -73,6 +75,33 @@ export const test = base.extend<ChatHubFixtures>({
|
|||
{ auto: true },
|
||||
],
|
||||
|
||||
chatHubEnabled: [
|
||||
async ({ n8n }, use) => {
|
||||
// Toggling chat hub requires the chatHub:manage scope, which only owners
|
||||
// and admins have. The test user may be a member/chat user, so drive this
|
||||
// through a dedicated owner context instead of n8n.api.
|
||||
const ownerApi = await n8n.api.createApiForUser(INSTANCE_OWNER_CREDENTIALS);
|
||||
const setEnabled = async (enabled: boolean) => {
|
||||
const response = await ownerApi.request.put('/rest/chat/enabled', { data: { enabled } });
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Failed to set Chat Hub enabled=${enabled}: ${response.status()} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Chat Hub is disabled by default; turn it on for these tests.
|
||||
await setEnabled(true);
|
||||
|
||||
await use(undefined);
|
||||
|
||||
// Restore the default (disabled) state so a shared instance isn't left enabled.
|
||||
await setEnabled(false);
|
||||
await ownerApi.request.dispose();
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
|
||||
project: async ({ n8n }, use) => {
|
||||
const project = await n8n.api.projects.createProject('ChatHub test project');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user