diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 8ea1fb84f31..e87cc4772e0 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -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, diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index a3523c5e7b7..ff5861e66b7 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -65,6 +65,7 @@ export { suggestedPromptsSchema, type MessageChunk, UpdateChatSettingsRequest, + UpdateChatEnabledRequest, ChatHubSemanticSearchSettings, type ChatProviderSettingsDto, type ChatSendMessageResponse, diff --git a/packages/@n8n/db/src/migrations/common/1784000000038-SetChatHubEnabledFromUsage.ts b/packages/@n8n/db/src/migrations/common/1784000000038-SetChatHubEnabledFromUsage.ts new file mode 100644 index 00000000000..6af733422cf --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000038-SetChatHubEnabledFromUsage.ts @@ -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() {} +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 68f4a2c2319..761e8a98910 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -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, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 78360b0ac5d..caa78d221b6 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -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 }; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts new file mode 100644 index 00000000000..d0b0a85364c --- /dev/null +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.controller.test.ts @@ -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(); + const moduleRegistry = { settings: settingsMap } as unknown as ModuleRegistry; + const controller = new ChatHubController(mock(), mock(), mock(), mock(), mock(), moduleRegistry); + + const req = mock(); + const res = mock(); + 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)); + }); + }); +}); diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.settings.controller.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.settings.controller.test.ts new file mode 100644 index 00000000000..ff991b1ef8e --- /dev/null +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.settings.controller.test.ts @@ -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(); + const moduleRegistry = mock(); + const logger = mock(); + logger.scoped.mockReturnValue(logger); + + const controller = new ChatHubSettingsController(settings, logger, moduleRegistry); + + const req = mock(); + const res = mock(); + + 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 }); + }); + }); +}); diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index 9ac9d5b4788..844dd270735 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -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( diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts index 08f899e42a7..811aeefa00c 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.controller.ts @@ -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(); } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts index 722dd129d14..8febb11bfd5 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts @@ -36,8 +36,10 @@ export class ChatHubSettingsService { async getEnabled(): Promise { 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'; } diff --git a/packages/cli/test/migration/1784000000038-set-chat-hub-enabled-from-usage.test.ts b/packages/cli/test/migration/1784000000038-set-chat-hub-enabled-from-usage.test.ts new file mode 100644 index 00000000000..1a7e44bdf46 --- /dev/null +++ b/packages/cli/test/migration/1784000000038-set-chat-hub-enabled-from-usage.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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'); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 77718d48b8c..32eebf53ac7 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/stores/settings.store.ts b/packages/frontend/editor-ui/src/app/stores/settings.store.ts index 149db76ba0a..9c769302231 100644 --- a/packages/frontend/editor-ui/src/app/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/settings.store.ts @@ -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(() => { diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts new file mode 100644 index 00000000000..96a71693a85 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.test.ts @@ -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: '
' }, + 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); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue index 35aa2e0b19d..51546f2b2b4 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/SettingsChatHubView.vue @@ -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(); }); @@ -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); +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts index 94d6894b77f..210d5dc1b69 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.api.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.test.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.test.ts index c4a62f78e62..36003fd23fd 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.test.ts @@ -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; + + 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts index ca0164283c2..03f668e14d2 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/chat.store.ts @@ -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, /** diff --git a/packages/testing/playwright/tests/e2e/chat-hub/fixtures.ts b/packages/testing/playwright/tests/e2e/chat-hub/fixtures.ts index cead2039c5e..19dd986bd57 100644 --- a/packages/testing/playwright/tests/e2e/chat-hub/fixtures.ts +++ b/packages/testing/playwright/tests/e2e/chat-hub/fixtures.ts @@ -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({ { 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');