feat(editor): Add placeholder page for inactive frontend modules (no-changelog) (#40553)

This commit is contained in:
Alex Antoniuk 2026-10-09 20:23:48 +00:00 committed by GitHub
parent ed4fbf4ed3
commit 3085f45f71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 289 additions and 10 deletions

View File

@ -274,6 +274,27 @@ routes: [
If `meta.middleware` has no `'custom'` entry, the route resolves. The state of the module then
makes no difference.
### Placeholder page for licensed features
An unlicensed module is inactive, so its settings links and routes disappear. To show a paywall
instead, set `placeholderPage` with the module's license feature:
```ts
placeholderPage: {
licenseFlag: 'logStreaming', // a key of `settings.enterprise`
component: async () => await import('./views/MyFeaturePaywall.vue'),
},
```
When the module is inactive and `licenseFlag` is off, its settings links stay and its routes show
this page. The `available` getter and route `rbac` still apply. The shell adds the `'custom'` guard
to these routes, so you don't need to list it. Route views must be lazy loaders. The shell picks
the view or the placeholder once, on the first load of the route.
If the module is licensed but inactive, for example turned off with `N8N_DISABLED_MODULES`, it
stays hidden. A module that is unlicensed and also turned off still shows the page, because the
frontend can't tell it apart from a module that is only unlicensed.
## Import-light descriptors
The descriptor file can import **types and the SDK only**. Load views lazily. Read a store inside

View File

@ -1,4 +1,6 @@
import type { FrontendSettings } from '@n8n/api-types';
import type { IMenuItem } from '@n8n/design-system';
import type { Component } from 'vue';
import type { RouteRecordRaw } from 'vue-router';
import type { ModuleBanner } from './banner';
@ -12,6 +14,16 @@ import type { ModuleSetupContext } from './setup';
import type { ModuleShortcut } from './shortcut';
import type { DynamicTabOptions } from './tabs';
/** A key of `settings.enterprise`, e.g. `'logStreaming'`. */
export type ModuleLicenseFlag = keyof Omit<FrontendSettings['enterprise'], 'projects'>;
/** A page, e.g. a paywall, shown instead of the module's routes when it isn't licensed. */
export type ModulePlaceholderPage = {
/** Show the page only when this feature is off. */
licenseFlag: ModuleLicenseFlag;
component: () => Promise<Component>;
};
/** A link in the settings sidebar. Shell and modules both use it. */
export type IMenuSettingItem = IMenuItem & {
/**
@ -48,6 +60,11 @@ export type FrontendModuleDescription = {
*/
adHocModalKeyPrefixes?: string[];
settingsPages?: IMenuSettingItem[];
/**
* Shown when the module is inactive and not licensed. Its settings links stay visible.
* A licensed module that is inactive (e.g. disabled) stays hidden.
*/
placeholderPage?: ModulePlaceholderPage;
// --- descriptor v2 (all optional, additive) ---

View File

@ -0,0 +1,140 @@
import type { FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { createPinia, setActivePinia } from 'pinia';
import { h } from 'vue';
import { createMemoryHistory, createRouter, type Router, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/app/constants';
import type { RouterMiddlewareType } from '@/app/types/router';
import { registerModuleRoutes } from '@/app/moduleInitializer/moduleInitializer';
const { fixtures } = vi.hoisted(() => {
const view = (text: string) => ({ name: text, render: () => h('div', text) });
const route = (
name: string,
middleware: RouterMiddlewareType[] = ['custom'],
): RouteRecordRaw => ({
path: `/${name}`,
name,
component: async () => await Promise.resolve(view(`${name}-view`)),
meta: { middleware },
});
const modules: FrontendModuleDescription[] = [
{
id: 'with-placeholder',
name: 'With placeholder',
description: '',
icon: 'box',
routes: [route('with-placeholder'), route('with-placeholder-unguarded', ['authenticated'])],
placeholderPage: {
licenseFlag: 'logStreaming',
component: async () => await Promise.resolve(view('paywall')),
},
},
{
id: 'without-placeholder',
name: 'Without placeholder',
description: '',
icon: 'box',
routes: [route('without-placeholder')],
},
];
return { fixtures: modules };
});
vi.mock('@/app/modules.manifest', () => ({ modules: fixtures }));
const setInstance = ({
activeModules = [],
licensed = false,
}: { activeModules?: string[]; licensed?: boolean } = {}) => {
const settingsStore = useSettingsStore();
settingsStore.settings = {
...settingsStore.settings,
activeModules,
enterprise: { ...settingsStore.settings.enterprise, logStreaming: licensed },
};
};
const createTestRouter = () => {
const stub = { render: () => null };
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/settings', name: VIEWS.SETTINGS, component: stub },
{ path: '/projects/:projectId', name: VIEWS.PROJECT_DETAILS, component: stub },
],
});
registerModuleRoutes(router);
return router;
};
// Like the router, runs the guard only when the route lists 'custom'
const checkRoute = (router: Router, name: string) => {
const to = router.resolve({ name });
const check = to.meta.middleware?.includes('custom')
? to.meta.middlewareOptions?.custom
: undefined;
return check?.({ to: { ...to, name }, from: router.currentRoute.value, next: vi.fn() });
};
const loadRouteView = async (router: Router, name: string) => {
const load = router.getRoutes().find((route) => route.name === name)?.components?.default as
| (() => Promise<{ name: string }>)
| undefined;
return (await load?.())?.name;
};
beforeEach(() => {
setActivePinia(createPinia());
});
describe('route guard', () => {
it('should allow an inactive, unlicensed module route when the module declares a placeholder page', () => {
setInstance();
expect(checkRoute(createTestRouter(), 'with-placeholder')).toBe(true);
});
it('should block an inactive, licensed module route with a placeholder page', () => {
setInstance({ licensed: true });
expect(checkRoute(createTestRouter(), 'with-placeholder')).toBe(false);
});
it('should block an inactive, licensed module route that does not list the guard', () => {
setInstance({ licensed: true });
expect(checkRoute(createTestRouter(), 'with-placeholder-unguarded')).toBe(false);
});
it('should block an inactive module route without a placeholder page', () => {
setInstance();
expect(checkRoute(createTestRouter(), 'without-placeholder')).toBe(false);
});
});
describe('route component', () => {
it('should load the placeholder page while the module is inactive', async () => {
setInstance();
expect(await loadRouteView(createTestRouter(), 'with-placeholder')).toBe('paywall');
});
it('should load the real view while the module is active', async () => {
setInstance({ activeModules: ['with-placeholder'] });
expect(await loadRouteView(createTestRouter(), 'with-placeholder')).toBe(
'with-placeholder-view',
);
});
it('should register routes before Pinia is installed', () => {
setActivePinia(undefined);
expect(() => createTestRouter()).not.toThrow();
});
});

View File

@ -1,5 +1,6 @@
import { type Router } from 'vue-router';
import { type RouteComponent, type RouteMeta, type Router, type RouteRecordRaw } from 'vue-router';
import {
type FrontendModuleDescription,
assertUniqueRouteNames,
modalRegistry,
registerResource,
@ -10,6 +11,7 @@ import {
import { VIEWS } from '@/app/constants';
import { modules } from '@/app/modules.manifest';
import { useUIStore } from '@/app/stores/ui.store';
import { showsPlaceholderPage } from '@/app/moduleInitializer/placeholderPage';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { AGENTS_SETTINGS_VIEW } from '@/features/agents/constants';
import {
@ -55,7 +57,11 @@ export const registerModuleSettingsPages = () => {
const uiStore = useUIStore();
modules.forEach((module) => {
if (module.settingsPages && module.settingsPages.length > 0) {
uiStore.registerSettingsPages(module.id, module.settingsPages);
uiStore.registerSettingsPages(
module.id,
module.settingsPages,
module.placeholderPage?.licenseFlag,
);
}
});
};
@ -70,7 +76,9 @@ const checkModuleAvailability = (options: any) => {
}
const settingsStore = useSettingsStore();
if (!settingsStore.isModuleActive(options.to.meta.moduleName)) {
return false;
// Open so an unlicensed module can show its placeholder page
const module = modules.find((m) => m.id === options.to.meta.moduleName);
return showsPlaceholderPage(module?.placeholderPage?.licenseFlag);
}
if (options.to.meta.moduleName === 'agents' && options.to.name !== AGENTS_SETTINGS_VIEW) {
return settingsStore.isAgentsEnabled;
@ -149,6 +157,40 @@ export const registerModuleParameterInputs = () => {
});
};
// Module views are lazy loaders (see the frontend module guide)
const isLazyView = (
component: RouteRecordRaw['component'],
): component is () => Promise<RouteComponent> => typeof component === 'function';
/**
* Loads `placeholderPage` instead of the view while the module is inactive.
* Adds the route guard, so a licensed but inactive module stays hidden.
* vue-router caches the first result, so the choice is made once per page load.
*/
const withPlaceholderPage = (
route: RouteRecordRaw,
module: FrontendModuleDescription,
): RouteRecordRaw => {
const { placeholderPage } = module;
if (!placeholderPage) return route;
const middleware = route.meta?.middleware ?? [];
const meta: RouteMeta = {
...route.meta,
middleware: middleware.includes('custom') ? middleware : [...middleware, 'custom'],
};
if (!route.component || !isLazyView(route.component)) return { ...route, meta };
const view = route.component;
return {
...route,
meta,
component: async () =>
useSettingsStore().isModuleActive(module.id)
? await view()
: await placeholderPage.component(),
};
};
/**
* Initialize module routes, done in main.ts
*/
@ -158,15 +200,16 @@ export const registerModuleRoutes = (router: Router) => {
modules.forEach((module) => {
module.routes?.forEach((route) => {
// Prepare the enhanced route with module metadata and custom middleware that checks module availability
const preparedRoute = withPlaceholderPage(route, module);
const enhancedRoute = {
...route,
...preparedRoute,
meta: {
...route.meta,
...preparedRoute.meta,
moduleName: module.id,
// Merge middleware options if custom middleware is present
...(route.meta?.middleware?.includes('custom') && {
...(preparedRoute.meta?.middleware?.includes('custom') && {
middlewareOptions: {
...route.meta?.middlewareOptions,
...preparedRoute.meta?.middlewareOptions,
custom: checkModuleAvailability,
},
}),

View File

@ -0,0 +1,6 @@
import type { ModuleLicenseFlag } from '@n8n/frontend-module-sdk';
import { useSettingsStore } from '@n8n/stores/settings.store';
/** An inactive module shows its placeholder page only while its license is off */
export const showsPlaceholderPage = (licenseFlag?: ModuleLicenseFlag) =>
!!licenseFlag && !useSettingsStore().isEnterpriseFeatureEnabled[licenseFlag];

View File

@ -1,6 +1,7 @@
import { OtelModule } from '@n8n/frontend-module-otel';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { IMenuSettingItem } from '@n8n/frontend-module-sdk';
import { createPinia, setActivePinia } from 'pinia';
import { useUIStore } from '@/app/stores/ui.store';
@ -49,3 +50,39 @@ describe('uiStore.settingsSidebarItems', () => {
expect(otelItem(uiStore)).toBeUndefined();
});
});
describe('uiStore.settingsSidebarItems with a placeholder page', () => {
const item: IMenuSettingItem = {
id: 'settings-with-placeholder',
label: 'With placeholder',
icon: 'lock',
};
const setLicensed = (licensed: boolean) => {
const settingsStore = useSettingsStore();
settingsStore.settings = {
...settingsStore.settings,
activeModules: [],
enterprise: { ...settingsStore.settings.enterprise, logStreaming: licensed },
};
};
const itemIds = () => useUIStore().settingsSidebarItems.map(({ id }) => id);
beforeEach(() => {
setActivePinia(createPinia());
});
it('should keep the pages of an inactive, unlicensed module that declares a placeholder page', () => {
setLicensed(false);
useUIStore().registerSettingsPages('with-placeholder', [item], 'logStreaming');
expect(itemIds()).toEqual(['settings-with-placeholder']);
});
it('should hide the pages of an inactive, licensed module that declares a placeholder page', () => {
setLicensed(true);
useUIStore().registerSettingsPages('with-placeholder', [item], 'logStreaming');
expect(itemIds()).toEqual([]);
});
});

View File

@ -33,8 +33,13 @@ import { useLocalStorage, useMediaQuery } from '@vueuse/core';
import type { EventBus } from '@n8n/utils/event-bus';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
import identity from 'lodash/identity';
import { modalRegistry, type IMenuSettingItem } from '@n8n/frontend-module-sdk';
import {
modalRegistry,
type IMenuSettingItem,
type ModuleLicenseFlag,
} from '@n8n/frontend-module-sdk';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { showsPlaceholderPage } from '@/app/moduleInitializer/placeholderPage';
let savedTheme: ThemeOption = 'system';
@ -173,6 +178,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
* when the corresponding module is active.
*/
const registeredSettingsPages = ref<Record<string, IMenuSettingItem[]>>({});
// Module id → license flag, for modules with a placeholder page
const placeholderLicenseFlags = ref<Record<string, ModuleLicenseFlag>>({});
const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 });
@ -285,7 +292,10 @@ export const useUIStore = defineStore(STORES.UI, () => {
const settingsSidebarItems = computed<IMenuSettingItem[]>(() => {
const items: IMenuSettingItem[] = [];
Object.entries(registeredSettingsPages.value).forEach(([moduleName, moduleItems]) => {
if (settingsStore.isModuleActive(moduleName)) {
if (
settingsStore.isModuleActive(moduleName) ||
showsPlaceholderPage(placeholderLicenseFlags.value[moduleName])
) {
items.push(...moduleItems.map((item) => ({ available: true, ...item })));
}
});
@ -599,8 +609,13 @@ export const useUIStore = defineStore(STORES.UI, () => {
moduleTabs.value[page][moduleName] = tabs;
};
const registerSettingsPages = (moduleName: string, items: IMenuSettingItem[]) => {
const registerSettingsPages = (
moduleName: string,
items: IMenuSettingItem[],
placeholderLicenseFlag?: ModuleLicenseFlag,
) => {
registeredSettingsPages.value[moduleName] = items;
if (placeholderLicenseFlag) placeholderLicenseFlags.value[moduleName] = placeholderLicenseFlag;
};
/**