diff --git a/packages/@n8n/module-cli/frontend-module-guide.md b/packages/@n8n/module-cli/frontend-module-guide.md index 56eb4dc6654..5db7c94db5b 100644 --- a/packages/@n8n/module-cli/frontend-module-guide.md +++ b/packages/@n8n/module-cli/frontend-module-guide.md @@ -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 diff --git a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts index 92b4d5e27b1..20663ffabe8 100644 --- a/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts +++ b/packages/frontend/@n8n/frontend-module-sdk/src/types/descriptor.ts @@ -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; + +/** 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; +}; + /** 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) --- diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.placeholderPage.test.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.placeholderPage.test.ts new file mode 100644 index 00000000000..9ec82e75c30 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.placeholderPage.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts index f0c894ec8a6..d760b96f424 100644 --- a/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/moduleInitializer.ts @@ -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 => 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, }, }), diff --git a/packages/frontend/editor-ui/src/app/moduleInitializer/placeholderPage.ts b/packages/frontend/editor-ui/src/app/moduleInitializer/placeholderPage.ts new file mode 100644 index 00000000000..c0e9ffcd81a --- /dev/null +++ b/packages/frontend/editor-ui/src/app/moduleInitializer/placeholderPage.ts @@ -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]; diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts index c8c2c9bc49a..1065ab02bf9 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.settingsPages.test.ts @@ -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([]); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/stores/ui.store.ts b/packages/frontend/editor-ui/src/app/stores/ui.store.ts index bc76a50708c..3a2b1381489 100644 --- a/packages/frontend/editor-ui/src/app/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/ui.store.ts @@ -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>({}); + // Module id → license flag, for modules with a placeholder page + const placeholderLicenseFlags = ref>({}); const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 }); @@ -285,7 +292,10 @@ export const useUIStore = defineStore(STORES.UI, () => { const settingsSidebarItems = computed(() => { 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; }; /**