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:
Jaakko Husso 2026-06-29 12:27:30 +03:00 committed by GitHub
parent 9977e7757a
commit c108dbf64a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 668 additions and 42 deletions

View File

@ -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,

View File

@ -65,6 +65,7 @@ export {
suggestedPromptsSchema,
type MessageChunk,
UpdateChatSettingsRequest,
UpdateChatEnabledRequest,
ChatHubSemanticSearchSettings,
type ChatProviderSettingsDto,
type ChatSendMessageResponse,

View File

@ -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() {}
}

View File

@ -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,
];

View File

@ -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 };

View File

@ -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));
});
});
});

View File

@ -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 });
});
});
});

View File

@ -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(

View File

@ -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();
}
}

View File

@ -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';
}

View File

@ -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');
});
});

View File

@ -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",

View File

@ -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(() => {

View File

@ -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);
});
});

View File

@ -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>

View File

@ -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,

View File

@ -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();
});
});

View File

@ -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,
/**

View File

@ -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');