refactor(core): Decouple module settings from frontend service (#16324)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Iván Ovejero
2025-06-18 10:00:02 +02:00
committed by GitHub
parent 49b9439ec0
commit 6ba8e0bebe
28 changed files with 227 additions and 172 deletions

View File

@@ -192,13 +192,25 @@ export interface FrontendSettings {
partialExecution: {
version: 1 | 2;
};
insights: {
enabled: boolean;
evaluation: {
quota: number;
};
/** Backend modules that were loaded during startup based on user configuration and pre-init check. */
loadedModules: string[];
}
export type FrontendModuleSettings = {
/**
* Client settings for [insights](https://docs.n8n.io/insights/) module.
*
* - `summary`: Whether the summary banner should be shown.
* - `dashboard`: Whether the full dashboard should be shown.
* - `dateRanges`: Date range filters available to select.
*/
insights?: {
summary: boolean;
dashboard: boolean;
dateRanges: InsightsDateRange[];
};
evaluation: {
quota: number;
};
}
};

View File

@@ -36,7 +36,6 @@ export {
type InsightsByWorkflow,
type InsightsByTime,
type InsightsDateRange,
INSIGHTS_DATE_RANGE_KEYS,
} from './schemas/insights.schema';
export {

View File

@@ -85,18 +85,9 @@ export const insightsByTimeDataSchemas = {
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
export const INSIGHTS_DATE_RANGE_KEYS = [
'day',
'week',
'2weeks',
'month',
'quarter',
'6months',
'year',
] as const;
export const insightsDateRangeSchema = z
.object({
key: z.enum(INSIGHTS_DATE_RANGE_KEYS),
key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']),
licensed: z.boolean(),
granularity: z.enum(['hour', 'day', 'week']),
})

View File

@@ -1,4 +1,5 @@
import { UNLIMITED_LICENSE_QUOTA, type BooleanLicenseFeature } from '@n8n/constants';
import type { BooleanLicenseFeature } from '@n8n/constants';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import { Service } from '@n8n/di';
import { UnexpectedError } from 'n8n-workflow';

View File

@@ -1,5 +1,6 @@
import { Container } from '@n8n/di';
import type { ModuleInterface } from '../module';
import { BackendModule } from '../module';
import { ModuleMetadata } from '../module-metadata';
@@ -14,34 +15,26 @@ describe('@BackendModule decorator', () => {
});
it('should register module in ModuleMetadata', () => {
@BackendModule()
class TestModule {
initialize() {}
}
@BackendModule({ name: 'test' })
class TestModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class);
const registeredModules = moduleMetadata.getClasses();
expect(registeredModules).toContain(TestModule);
expect(registeredModules).toHaveLength(1);
});
it('should register multiple modules', () => {
@BackendModule()
class FirstModule {
initialize() {}
}
@BackendModule({ name: 'test-1' })
class FirstModule implements ModuleInterface {}
@BackendModule()
class SecondModule {
initialize() {}
}
@BackendModule({ name: 'test-2' })
class SecondModule implements ModuleInterface {}
@BackendModule()
class ThirdModule {
initialize() {}
}
@BackendModule({ name: 'test-3' })
class ThirdModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class);
const registeredModules = moduleMetadata.getClasses();
expect(registeredModules).toContain(FirstModule);
expect(registeredModules).toContain(SecondModule);
@@ -49,55 +42,26 @@ describe('@BackendModule decorator', () => {
expect(registeredModules).toHaveLength(3);
});
it('should work with modules without initialize method', () => {
@BackendModule()
class TestModule {}
const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class);
expect(registeredModules).toContain(TestModule);
expect(registeredModules).toHaveLength(1);
});
it('should support async initialize method', async () => {
const mockInitialize = jest.fn();
@BackendModule()
class TestModule {
async initialize() {
mockInitialize();
}
}
const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class);
expect(registeredModules).toContain(TestModule);
const moduleInstance = new TestModule();
await moduleInstance.initialize();
expect(mockInitialize).toHaveBeenCalled();
});
describe('ModuleMetadata', () => {
it('should allow retrieving and checking registered modules', () => {
@BackendModule()
class FirstModule {}
@BackendModule()
class SecondModule {}
const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class);
expect(registeredModules).toContain(FirstModule);
expect(registeredModules).toContain(SecondModule);
});
});
it('should apply Service decorator', () => {
@BackendModule()
class TestModule {}
@BackendModule({ name: 'test' })
class TestModule implements ModuleInterface {}
expect(Container.has(TestModule)).toBe(true);
});
it('stores the test name and licenseFlag flag in the metadata', () => {
const name = 'test';
const licenseFlag = 'feat:ldap';
@BackendModule({ name, licenseFlag })
class TestModule implements ModuleInterface {}
const registeredModules = moduleMetadata.getEntries();
expect(registeredModules).toHaveLength(1);
const [moduleName, options] = registeredModules[0];
expect(moduleName).toBe(name);
expect(options.licenseFlag).toBe(licenseFlag);
expect(options.class).toBe(TestModule);
});
});

View File

@@ -1,2 +1,2 @@
export { ModuleInterface, BackendModule, EntityClass } from './module';
export { ModuleInterface, BackendModule, EntityClass, ModuleSettings } from './module';
export { ModuleMetadata } from './module-metadata';

View File

@@ -16,6 +16,10 @@ export class ModuleMetadata {
}
getEntries() {
return [...this.modules.values()];
return [...this.modules.entries()];
}
getClasses() {
return [...this.modules.values()].map((entry) => entry.class);
}
}

View File

@@ -18,9 +18,12 @@ export interface BaseEntity {
export type EntityClass = new () => BaseEntity;
export type ModuleSettings = Record<string, unknown>;
export interface ModuleInterface {
init?(): void | Promise<void>;
init?(): Promise<void>;
entities?(): EntityClass[];
settings?(): Promise<ModuleSettings>;
}
export type ModuleClass = Constructable<ModuleInterface>;
@@ -28,9 +31,9 @@ export type ModuleClass = Constructable<ModuleInterface>;
export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES];
export const BackendModule =
(opts?: { licenseFlag: LicenseFlag }): ClassDecorator =>
(opts: { name: string; licenseFlag?: LicenseFlag }): ClassDecorator =>
(target) => {
Container.get(ModuleMetadata).register(target.name, {
Container.get(ModuleMetadata).register(opts.name, {
class: target as unknown as ModuleClass,
licenseFlag: opts?.licenseFlag,
});

View File

@@ -2,7 +2,7 @@ import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
@BackendModule({ licenseFlag: 'feat:externalSecrets' })
@BackendModule({ name: 'external-secrets', licenseFlag: 'feat:externalSecrets' })
export class ExternalSecretsModule implements ModuleInterface {
async init() {
await import('./external-secrets.controller.ee');

View File

@@ -26,7 +26,6 @@ import type { InsightsRaw } from '../database/entities/insights-raw';
import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
import { InsightsCollectionService } from '../insights-collection.service';
import { InsightsCompactionService } from '../insights-compaction.service';
import { getAvailableDateRanges } from '../insights-helpers';
import type { InsightsPruningService } from '../insights-pruning.service';
import { InsightsConfig } from '../insights.config';
import { InsightsService } from '../insights.service';
@@ -584,16 +583,26 @@ describe('getInsightsByTime', () => {
describe('getAvailableDateRanges', () => {
let licenseMock: jest.Mocked<LicenseState>;
let insightsService: InsightsService;
beforeAll(() => {
licenseMock = mock<LicenseState>();
insightsService = new InsightsService(
mock(),
mock(),
mock(),
mock(),
licenseMock,
mock(),
mockLogger(),
);
});
test('returns correct ranges when hourly data is enabled and max history is unlimited', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = getAvailableDateRanges(licenseMock);
const result = insightsService.getAvailableDateRanges();
expect(result).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },
@@ -610,7 +619,7 @@ describe('getAvailableDateRanges', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = getAvailableDateRanges(licenseMock);
const result = insightsService.getAvailableDateRanges();
expect(result).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },
@@ -627,7 +636,7 @@ describe('getAvailableDateRanges', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = getAvailableDateRanges(licenseMock);
const result = insightsService.getAvailableDateRanges();
expect(result).toEqual([
{ key: 'day', licensed: false, granularity: 'hour' },
@@ -644,7 +653,7 @@ describe('getAvailableDateRanges', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(5);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = getAvailableDateRanges(licenseMock);
const result = insightsService.getAvailableDateRanges();
expect(result).toEqual([
{ key: 'day', licensed: false, granularity: 'hour' },
@@ -661,7 +670,7 @@ describe('getAvailableDateRanges', () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = getAvailableDateRanges(licenseMock);
const result = insightsService.getAvailableDateRanges();
expect(result).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },

View File

@@ -1,31 +0,0 @@
import { type InsightsDateRange, INSIGHTS_DATE_RANGE_KEYS } from '@n8n/api-types';
import type { LicenseState } from '@n8n/backend-common';
export const keyRangeToDays: Record<InsightsDateRange['key'], number> = {
day: 1,
week: 7,
'2weeks': 14,
month: 30,
quarter: 90,
'6months': 180,
year: 365,
};
/**
* Returns the available date ranges with their license authorization and time granularity
* when grouped by time.
*/
export function getAvailableDateRanges(licenseState: LicenseState): InsightsDateRange[] {
const maxHistoryInDays =
licenseState.getInsightsMaxHistory() === -1
? Number.MAX_SAFE_INTEGER
: licenseState.getInsightsMaxHistory();
const isHourlyDateLicensed = licenseState.isInsightsHourlyDataLicensed();
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
key,
licensed:
key === 'day' ? (isHourlyDateLicensed ?? false) : maxHistoryInDays >= keyRangeToDays[key],
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
}));
}

View File

@@ -0,0 +1,19 @@
export const INSIGHTS_DATE_RANGE_KEYS = [
'day',
'week',
'2weeks',
'month',
'quarter',
'6months',
'year',
] as const;
export const keyRangeToDays: Record<(typeof INSIGHTS_DATE_RANGE_KEYS)[number], number> = {
day: 1,
week: 7,
'2weeks': 14,
month: 30,
quarter: 90,
'6months': 180,
year: 365,
};

View File

@@ -8,7 +8,7 @@ import { InsightsByPeriod } from './database/entities/insights-by-period';
import { InsightsMetadata } from './database/entities/insights-metadata';
import { InsightsRaw } from './database/entities/insights-raw';
@BackendModule()
@BackendModule({ name: 'insights' })
export class InsightsModule implements ModuleInterface {
async init() {
const { instanceType } = Container.get(InstanceSettings);
@@ -28,4 +28,9 @@ export class InsightsModule implements ModuleInterface {
entities() {
return [InsightsByPeriod, InsightsMetadata, InsightsRaw];
}
async settings() {
const { InsightsService } = await import('./insights.service');
return Container.get(InsightsService).settings();
}
}

View File

@@ -10,8 +10,8 @@ import { NumberToType } from './database/entities/insights-shared';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
import { InsightsCollectionService } from './insights-collection.service';
import { InsightsCompactionService } from './insights-compaction.service';
import { getAvailableDateRanges, keyRangeToDays } from './insights-helpers';
import { InsightsPruningService } from './insights-pruning.service';
import { INSIGHTS_DATE_RANGE_KEYS, keyRangeToDays } from './insights.constants';
@Service()
export class InsightsService {
@@ -27,6 +27,14 @@ export class InsightsService {
this.logger = this.logger.scoped('insights');
}
settings() {
return {
summary: this.licenseState.isInsightsSummaryLicensed(),
dashboard: this.licenseState.isInsightsDashboardLicensed(),
dateRanges: this.getAvailableDateRanges(),
};
}
startTimers() {
this.collectionService.startFlushingTimer();
@@ -191,9 +199,8 @@ export class InsightsService {
getMaxAgeInDaysAndGranularity(
dateRangeKey: InsightsDateRange['key'],
): InsightsDateRange & { maxAgeInDays: number } {
const dateRange = getAvailableDateRanges(this.licenseState).find(
(range) => range.key === dateRangeKey,
);
const dateRange = this.getAvailableDateRanges().find((range) => range.key === dateRangeKey);
if (!dateRange) {
// Not supposed to happen if we trust the dateRangeKey type
throw new UserError('The selected date range is not available');
@@ -207,4 +214,29 @@ export class InsightsService {
return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] };
}
/**
* Returns the available date ranges with their license authorization and time granularity
* when grouped by time.
*/
getAvailableDateRanges(): DateRange[] {
const maxHistoryInDays =
this.licenseState.getInsightsMaxHistory() === -1
? Number.MAX_SAFE_INTEGER
: this.licenseState.getInsightsMaxHistory();
const isHourlyDateLicensed = this.licenseState.isInsightsHourlyDataLicensed();
return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({
key,
licensed:
key === 'day' ? (isHourlyDateLicensed ?? false) : maxHistoryInDays >= keyRangeToDays[key],
granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week',
}));
}
}
type DateRange = {
key: 'day' | 'week' | '2weeks' | 'month' | 'quarter' | '6months' | 'year';
licensed: boolean;
granularity: 'hour' | 'day' | 'week';
};

View File

@@ -1,6 +1,6 @@
import { LicenseState, Logger } from '@n8n/backend-common';
import { LifecycleMetadata, ModuleMetadata } from '@n8n/decorators';
import type { LifecycleContext, EntityClass } from '@n8n/decorators';
import type { LifecycleContext, EntityClass, ModuleSettings } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import type { ExecutionLifecycleHooks } from 'n8n-core';
import type {
@@ -17,6 +17,8 @@ import type {
export class ModuleRegistry {
readonly entities: EntityClass[] = [];
readonly settings: Map<string, ModuleSettings> = new Map();
constructor(
private readonly moduleMetadata: ModuleMetadata,
private readonly lifecycleMetadata: LifecycleMetadata,
@@ -25,17 +27,25 @@ export class ModuleRegistry {
) {}
async initModules() {
for (const { class: ModuleClass, licenseFlag } of this.moduleMetadata.getEntries()) {
for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) {
const { licenseFlag, class: ModuleClass } = moduleEntry;
if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) {
this.logger.debug(`Skipped init for unlicensed module "${ModuleClass.name}"`);
this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`);
continue;
}
await Container.get(ModuleClass).init?.();
const moduleSettings = await Container.get(ModuleClass).settings?.();
if (!moduleSettings) continue;
this.settings.set(moduleName, moduleSettings);
}
}
addEntities() {
for (const { class: ModuleClass } of this.moduleMetadata.getEntries()) {
for (const ModuleClass of this.moduleMetadata.getClasses()) {
const entities = Container.get(ModuleClass).entities?.();
if (!entities || entities.length === 0) continue;

View File

@@ -271,6 +271,12 @@ export class Server extends AbstractServer {
ResponseHelper.send(async () => frontendService.getSettings()),
);
// Returns settings for all loaded modules
this.app.get(
`/${this.restEndpoint}/module-settings`,
ResponseHelper.send(async () => frontendService.getModuleSettings()),
);
// Return Sentry config as a static file
this.app.get(`/${this.restEndpoint}/sentry.js`, (_, res) => {
res.type('js');

View File

@@ -17,7 +17,7 @@ import { CredentialsOverwrites } from '@/credentials-overwrites';
import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee';
import { License } from '@/license';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { getAvailableDateRanges as getInsightsAvailableDateRanges } from '@/modules/insights/insights-helpers';
import { ModuleRegistry } from '@/modules/module-registry';
import { ModulesConfig } from '@/modules/modules.config';
import { isApiEnabled } from '@/public-api';
import { PushConfig } from '@/push/push.config';
@@ -53,6 +53,7 @@ export class FrontendService {
private readonly pushConfig: PushConfig,
private readonly binaryDataConfig: BinaryDataConfig,
private readonly licenseState: LicenseState,
private readonly moduleRegistry: ModuleRegistry,
) {
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
void this.generateTypes();
@@ -252,15 +253,10 @@ export class FrontendService {
folders: {
enabled: false,
},
insights: {
enabled: this.modulesConfig.modules.includes('insights'),
summary: true,
dashboard: false,
dateRanges: [],
},
evaluation: {
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
},
loadedModules: this.modulesConfig.modules,
};
}
@@ -388,13 +384,6 @@ export class FrontendService {
this.settings.aiCredits.credits = this.license.getAiCredits();
}
Object.assign(this.settings.insights, {
enabled: this.modulesConfig.loadedModules.has('insights'),
summary: this.licenseState.isInsightsSummaryLicensed(),
dashboard: this.licenseState.isInsightsDashboardLicensed(),
dateRanges: getInsightsAvailableDateRanges(this.licenseState),
});
this.settings.mfa.enabled = this.globalConfig.mfa.enabled;
this.settings.executionMode = config.getEnv('executions.mode');
@@ -411,6 +400,10 @@ export class FrontendService {
return this.settings;
}
getModuleSettings() {
return Object.fromEntries(this.moduleRegistry.settings);
}
private writeStaticJSON(name: string, data: INodeTypeBaseDescription[] | ICredentialType[]) {
const { staticCacheDir } = this.instanceSettings;
const filePath = path.join(staticCacheDir, `types/${name}.json`);

View File

@@ -7,7 +7,6 @@ import { randomString } from 'n8n-workflow';
import { DbConnection } from '@/databases/db-connection';
import { DbConnectionOptions } from '@/databases/db-connection-options';
import { ModuleRegistry } from '@/modules/module-registry';
export const testDbPrefix = 'n8n_test_';
@@ -38,8 +37,6 @@ export async function init() {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
await dbConnection.migrate();
await Container.get(ModuleRegistry).initModules();
}
export function isReady() {

View File

@@ -14,6 +14,7 @@ import { AUTH_COOKIE_NAME } from '@/constants';
import { ControllerRegistry } from '@/controller.registry';
import { License } from '@/license';
import { rawBodyReader, bodyParser } from '@/middlewares';
import { ModuleRegistry } from '@/modules/module-registry';
import { PostHogClient } from '@/posthog';
import { Push } from '@/push';
import type { APIRequest } from '@/requests';
@@ -300,6 +301,7 @@ export const setupTestServer = ({
}
}
await Container.get(ModuleRegistry).initModules();
Container.get(ControllerRegistry).activate(app);
}
});

View File

@@ -12,6 +12,7 @@ export * from './orchestration';
export * from './prompts';
export * from './roles';
export * from './settings';
export * from './module-settings';
export * from './sso';
export * from './ui';
export * from './versions';

View File

@@ -0,0 +1,8 @@
import type { FrontendModuleSettings } from '@n8n/api-types';
import type { IRestApiContext } from '../types';
import { makeRestApiRequest } from '../utils';
export async function getModuleSettings(context: IRestApiContext): Promise<FrontendModuleSettings> {
return await makeRestApiRequest(context, 'GET', '/module-settings');
}

View File

@@ -145,21 +145,8 @@ export const defaultSettings: FrontendSettings = {
folders: {
enabled: false,
},
insights: {
enabled: false,
summary: true,
dashboard: false,
dateRanges: [
{ key: 'day', licensed: true, granularity: 'hour' },
{ key: 'week', licensed: true, granularity: 'day' },
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: false, granularity: 'day' },
{ key: 'quarter', licensed: false, granularity: 'week' },
{ key: '6months', licensed: false, granularity: 'week' },
{ key: 'year', licensed: false, granularity: 'week' },
],
},
evaluation: {
quota: 0,
},
loadedModules: [],
};

View File

@@ -112,7 +112,7 @@ const mainMenuItems = computed(() => [
position: 'bottom',
route: { to: { name: VIEWS.INSIGHTS } },
available:
settingsStore.settings.insights.enabled &&
settingsStore.settings.loadedModules.includes('insights') &&
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
},
{

View File

@@ -13,9 +13,25 @@ const renderComponent = createComponentRenderer(InsightsDashboard, {
},
});
const moduleSettings = {
insights: {
summary: true,
dashboard: true,
dateRanges: [
{
key: 'day',
licensed: true,
granularity: 'hour',
},
],
},
};
describe('InsightsDashboard', () => {
beforeEach(() => {
createTestingPinia({ initialState: { settings: { settings: defaultSettings } } });
createTestingPinia({
initialState: { settings: { settings: defaultSettings, moduleSettings } },
});
});
it('should render without error', () => {

View File

@@ -18,8 +18,13 @@ export const useInsightsStore = defineStore('insights', () => {
() => getResourcePermissions(usersStore.currentUser?.globalScopes).insights,
);
const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled);
const isDashboardEnabled = computed(() => settingsStore.settings.insights.dashboard);
const isInsightsEnabled = computed(() =>
settingsStore.settings.loadedModules.includes('insights'),
);
const isDashboardEnabled = computed(
() => settingsStore.moduleSettings.insights?.dashboard ?? false,
);
const isSummaryEnabled = computed(
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
@@ -64,7 +69,7 @@ export const useInsightsStore = defineStore('insights', () => {
{ immediate: false, resetOnExecute: false },
);
const dateRanges = computed(() => settingsStore.settings.insights.dateRanges);
const dateRanges = computed(() => settingsStore.moduleSettings.insights?.dateRanges ?? []);
return {
globalInsightsPermissions,

View File

@@ -1,9 +1,14 @@
import { computed, ref } from 'vue';
import Bowser from 'bowser';
import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types';
import type {
IUserManagementSettings,
FrontendSettings,
FrontendModuleSettings,
} from '@n8n/api-types';
import * as eventsApi from '@n8n/rest-api-client/api/events';
import * as settingsApi from '@n8n/rest-api-client/api/settings';
import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings';
import * as promptsApi from '@n8n/rest-api-client/api/prompts';
import { testHealthEndpoint } from '@/api/templates';
import {
@@ -26,6 +31,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const i18n = useI18n();
const initialized = ref(false);
const settings = ref<FrontendSettings>({} as FrontendSettings);
const moduleSettings = ref<FrontendModuleSettings>({});
const userManagement = ref<IUserManagementSettings>({
quota: -1,
showSetupOnFirstLoad: false,
@@ -178,6 +184,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
const loadedModules = computed(() => settings.value.loadedModules);
const setSettings = (newSettings: FrontendSettings) => {
settings.value = newSettings;
userManagement.value = newSettings.userManagement;
@@ -325,6 +333,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
settings.value = {} as FrontendSettings;
};
const getModuleSettings = async () => {
const fetched = await moduleSettingsApi.getModuleSettings(useRootStore().restApiContext);
moduleSettings.value = fetched;
};
/**
* (Experimental) Minimum zoom level of the canvas to render node settings in place of nodes, without opening NDV
*/
@@ -402,5 +415,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
getSettings,
setSettings,
initialize,
loadedModules,
getModuleSettings,
moduleSettings,
};
});

View File

@@ -46,6 +46,7 @@ let telemetry: ReturnType<typeof useTelemetry>;
describe('SigninView', () => {
const signInWithValidUser = async () => {
settingsStore.isCloudDeployment = false;
settingsStore.loadedModules = [];
usersStore.loginWithCreds.mockResolvedValueOnce();
const { getByRole, queryByTestId, container } = renderComponent();

View File

@@ -144,6 +144,11 @@ const login = async (form: LoginRequestDto) => {
}
}
await settingsStore.getSettings();
if (settingsStore.loadedModules.length > 0) {
await settingsStore.getModuleSettings();
}
toast.clearAllStickyNotifications();
telemetry.track('User attempted to login', {