feat(editor): Using special env vars as feature flags in the frontend (#17355)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Csaba Tuncsik
2025-07-17 16:06:21 +02:00
committed by GitHub
parent 5cc3b31b81
commit d36abb5a3a
13 changed files with 632 additions and 6 deletions

View File

@@ -0,0 +1,37 @@
describe('Environment Feature Flags', () => {
it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => {
cy.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: true,
});
cy.signinAsOwner();
cy.intercept('GET', '/rest/settings').as('getSettings');
cy.visit('/');
cy.wait('@getSettings').then((interception) => {
expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
'true',
);
});
});
it('should reset feature flags at runtime', () => {
cy.setEnvFeatureFlags({
N8N_ENV_FEAT_TEST: true,
});
cy.signinAsOwner();
cy.intercept('GET', '/rest/settings').as('getSettings');
cy.visit('/');
cy.wait('@getSettings').then((interception) => {
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal(
'true',
);
});
cy.clearEnvFeatureFlags();
cy.visit('/');
cy.wait('@getSettings').then((interception) => {
expect(interception.response?.body.data.envFeatureFlags).to.be.an('object');
expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.be.undefined;
});
});
});

View File

@@ -14,6 +14,14 @@ function runTests(options) {
process.env.E2E_TESTS = 'true';
process.env.NODE_OPTIONS = '--dns-result-order=ipv4first';
// Automatically pass through any N8N_ENV_FEAT_* environment variables
Object.keys(process.env).forEach((key) => {
if (key.startsWith('N8N_ENV_FEAT_')) {
// These are already in process.env and will be inherited by the spawned process
console.log(`Passing through environment feature flag: ${key}=${process.env[key]}`);
}
});
if (options.customEnv) {
Object.keys(options.customEnv).forEach((key) => {
process.env[key] = options.customEnv[key];
@@ -21,7 +29,11 @@ function runTests(options) {
}
const cmd = `start-server-and-test ${options.startCommand} ${options.url} '${options.testCommand}'`;
const testProcess = spawn(cmd, [], { stdio: 'inherit', shell: true });
const testProcess = spawn(cmd, [], {
stdio: 'inherit',
shell: true,
env: process.env, // TODO: Maybe pass only the necessary environment variables instead of all
});
// Listen for termination signals to properly kill the test process
process.on('SIGINT', () => {

View File

@@ -1,5 +1,5 @@
import 'cypress-real-events';
import type { FrontendSettings } from '@n8n/api-types';
import type { FrontendSettings, N8nEnvFeatFlags } from '@n8n/api-types';
import FakeTimers from '@sinonjs/fake-timers';
import {
@@ -115,6 +115,25 @@ Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature,
Cypress.Commands.add('enableQueueMode', () => setQueueMode(true));
Cypress.Commands.add('disableQueueMode', () => setQueueMode(false));
const setEnvFeatureFlags = (flags: N8nEnvFeatFlags) =>
cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`, {
flags,
});
const getEnvFeatureFlags = () =>
cy.request('GET', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`);
// Environment feature flags commands (using E2E API)
Cypress.Commands.add('setEnvFeatureFlags', (flags: N8nEnvFeatFlags) =>
setEnvFeatureFlags(flags).then((response) => response.body.data),
);
Cypress.Commands.add('clearEnvFeatureFlags', () =>
setEnvFeatureFlags({}).then((response) => response.body.data),
);
Cypress.Commands.add('getEnvFeatureFlags', () =>
getEnvFeatureFlags().then((response) => response.body.data),
);
Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => {
if (Cypress.isBrowser('chrome')) {
cy.wrap(

View File

@@ -1,7 +1,7 @@
// Load type definitions that come with Cypress module
/// <reference types="cypress" />
import type { FrontendSettings, PushPayload, PushType } from '@n8n/api-types';
import type { FrontendSettings, PushPayload, PushType, N8nEnvFeatFlags } from '@n8n/api-types';
Cypress.Keyboard.defaults({
keystrokeDelay: 0,
@@ -51,6 +51,11 @@ declare global {
enableQueueMode(): void;
disableQueueMode(): void;
changeQuota(feature: string, value: number): void;
setEnvFeatureFlags(
flags: N8nEnvFeatFlags,
): Chainable<{ success: boolean; flags?: N8nEnvFeatFlags; error?: string }>;
clearEnvFeatureFlags(): Chainable<{ success: boolean; flags: N8nEnvFeatFlags }>;
getEnvFeatureFlags(): Chainable<N8nEnvFeatFlags>;
waitForLoad(waitForIntercepts?: boolean): void;
grantBrowserPermissions(...permissions: string[]): void;
readClipboard(): Chainable<string>;

View File

@@ -202,6 +202,7 @@ export interface FrontendSettings {
/** Backend modules that were initialized during startup. */
activeModules: string[];
envFeatureFlags: N8nEnvFeatFlags;
}
export type FrontendModuleSettings = {
@@ -218,3 +219,6 @@ export type FrontendModuleSettings = {
dateRanges: InsightsDateRange[];
};
};
export type N8nEnvFeatFlagValue = boolean | string | number | undefined;
export type N8nEnvFeatFlags = Record<`N8N_ENV_FEAT_${Uppercase<string>}`, N8nEnvFeatFlagValue>;

View File

@@ -3,7 +3,7 @@ import { Logger } from '@n8n/backend-common';
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import { SettingsRepository, UserRepository } from '@n8n/db';
import { Patch, Post, RestController } from '@n8n/decorators';
import { Get, Patch, Post, RestController } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { Request } from 'express';
import { v4 as uuid } from 'uuid';
@@ -17,6 +17,7 @@ import { License } from '@/license';
import { MfaService } from '@/mfa/mfa.service';
import { Push } from '@/push';
import { CacheService } from '@/services/cache/cache.service';
import { FrontendService } from '@/services/frontend.service';
import { PasswordUtility } from '@/services/password.utility';
if (!inE2ETests) {
@@ -154,6 +155,7 @@ export class E2EController {
private readonly passwordUtility: PasswordUtility,
private readonly eventBus: MessageEventBus,
private readonly userRepository: UserRepository,
private readonly frontendService: FrontendService,
) {
license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false;
@@ -207,6 +209,47 @@ export class E2EController {
return { success: true, message: `Queue mode set to ${config.getEnv('executions.mode')}` };
}
@Get('/env-feature-flags', { skipAuth: true })
async getEnvFeatureFlags() {
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
return currentFlags;
}
@Patch('/env-feature-flags', { skipAuth: true })
async setEnvFeatureFlags(req: Request<{}, {}, { flags: Record<string, string> }>) {
const { flags } = req.body;
// Validate that all flags start with N8N_ENV_FEAT_
for (const key of Object.keys(flags)) {
if (!key.startsWith('N8N_ENV_FEAT_')) {
return {
success: false,
message: `Invalid flag key: ${key}. Must start with N8N_ENV_FEAT_`,
};
}
}
// Clear existing N8N_ENV_FEAT_ environment variables
for (const key of Object.keys(process.env)) {
if (key.startsWith('N8N_ENV_FEAT_')) {
delete process.env[key];
}
}
// Set new environment variables
for (const [key, value] of Object.entries(flags)) {
process.env[key] = value;
}
// Return the current environment feature flags
const currentFlags = this.frontendService.getSettings().envFeatureFlags;
return {
success: true,
message: 'Environment feature flags updated',
flags: currentFlags,
};
}
private resetFeatures() {
for (const feature of Object.keys(this.enabledFeatures)) {
this.enabledFeatures[feature as BooleanLicenseFeature] = false;

View File

@@ -0,0 +1,269 @@
import { mock } from 'jest-mock-extended';
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common';
import type { InstanceSettings, BinaryDataConfig } from 'n8n-core';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import type { CredentialTypes } from '@/credential-types';
import type { CredentialsOverwrites } from '@/credentials-overwrites';
import type { License } from '@/license';
import type { UserManagementMailer } from '@/user-management/email';
import type { UrlService } from '@/services/url.service';
import type { PushConfig } from '@/push/push.config';
import type { MfaService } from '@/mfa/mfa.service';
import { FrontendService } from '@/services/frontend.service';
describe('FrontendService', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = process.env;
jest.clearAllMocks();
});
afterEach(() => {
process.env = originalEnv;
});
describe('envFeatureFlags functionality', () => {
const createMockService = () => {
const globalConfig = mock<GlobalConfig>({
database: { type: 'sqlite' },
endpoints: { rest: 'rest' },
diagnostics: { enabled: false },
templates: { enabled: false, host: '' },
nodes: { communityPackages: { enabled: false } },
tags: { disabled: false },
logging: { level: 'info' },
hiringBanner: { enabled: false },
versionNotifications: {
enabled: false,
endpoint: '',
whatsNewEnabled: false,
whatsNewEndpoint: '',
infoUrl: '',
},
personalization: { enabled: false },
defaultLocale: 'en',
auth: { cookie: { secure: false } },
generic: { releaseChannel: 'stable', timezone: 'UTC' },
publicApi: { path: 'api', swaggerUiDisabled: false },
workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' },
executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 },
hideUsagePage: false,
license: { tenantId: 1 },
mfa: { enabled: false },
deployment: { type: 'default' },
workflowHistory: { enabled: false },
partialExecutions: { version: 1 },
path: '',
sso: {
ldap: { loginEnabled: false },
saml: { loginEnabled: false },
oidc: { loginEnabled: false },
},
});
const logger = mock<Logger>();
const instanceSettings = mock<InstanceSettings>({
isDocker: false,
instanceId: 'test-instance',
isMultiMain: false,
hostId: 'test-host',
staticCacheDir: '/tmp/test-cache',
});
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>({
addPostProcessor: jest.fn(),
types: {
credentials: [],
nodes: [],
},
});
const binaryDataConfig = mock<BinaryDataConfig>({
mode: 'default',
availableModes: ['default'],
});
const credentialTypes = mock<CredentialTypes>({
getParentTypes: jest.fn().mockReturnValue([]),
});
const credentialsOverwrites = mock<CredentialsOverwrites>({
getAll: jest.fn().mockReturnValue({}),
});
const license = mock<License>({
getUsersLimit: jest.fn().mockReturnValue(100),
getPlanName: jest.fn().mockReturnValue('Community'),
getConsumerId: jest.fn().mockReturnValue('test-consumer'),
isSharingEnabled: jest.fn().mockReturnValue(false),
isLogStreamingEnabled: jest.fn().mockReturnValue(false),
isLdapEnabled: jest.fn().mockReturnValue(false),
isSamlEnabled: jest.fn().mockReturnValue(false),
isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false),
isVariablesEnabled: jest.fn().mockReturnValue(false),
isSourceControlLicensed: jest.fn().mockReturnValue(false),
isExternalSecretsEnabled: jest.fn().mockReturnValue(false),
isLicensed: jest.fn().mockReturnValue(false),
isDebugInEditorLicensed: jest.fn().mockReturnValue(false),
isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false),
isWorkerViewLicensed: jest.fn().mockReturnValue(false),
isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false),
isApiKeyScopesEnabled: jest.fn().mockReturnValue(false),
getVariablesLimit: jest.fn().mockReturnValue(0),
getTeamProjectLimit: jest.fn().mockReturnValue(0),
isBinaryDataS3Licensed: jest.fn().mockReturnValue(false),
isAiAssistantEnabled: jest.fn().mockReturnValue(false),
isAskAiEnabled: jest.fn().mockReturnValue(false),
isAiCreditsEnabled: jest.fn().mockReturnValue(false),
getAiCredits: jest.fn().mockReturnValue(0),
isFoldersEnabled: jest.fn().mockReturnValue(false),
});
const mailer = mock<UserManagementMailer>({
isEmailSetUp: false,
});
const urlService = mock<UrlService>({
getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'),
});
const securityConfig = mock<SecurityConfig>({
blockFileAccessToN8nFiles: false,
});
const pushConfig = mock<PushConfig>({
backend: 'websocket',
});
const licenseState = mock<LicenseState>({
isOidcLicensed: jest.fn().mockReturnValue(false),
isMFAEnforcementLicensed: jest.fn().mockReturnValue(false),
getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0),
});
const moduleRegistry = mock<ModuleRegistry>({
getActiveModules: jest.fn().mockReturnValue([]),
});
const mfaService = mock<MfaService>({
isMFAEnforced: jest.fn().mockReturnValue(false),
});
return new FrontendService(
globalConfig,
logger,
loadNodesAndCredentials,
credentialTypes,
credentialsOverwrites,
license,
mailer,
instanceSettings,
urlService,
securityConfig,
pushConfig,
binaryDataConfig,
licenseState,
moduleRegistry,
mfaService,
);
};
describe('collectEnvFeatureFlags', () => {
it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => {
process.env = {
N8N_ENV_FEAT_TEST_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
N8N_ENV_FEAT_NUMERIC_FLAG: '123',
REGULAR_ENV_VAR: 'should-not-be-included',
N8N_OTHER_PREFIX: 'should-not-be-included',
};
const service = createMockService();
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
const result = collectEnvFeatureFlags();
expect(result).toEqual({
N8N_ENV_FEAT_TEST_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
N8N_ENV_FEAT_NUMERIC_FLAG: '123',
});
});
it('should return empty object when no N8N_ENV_FEAT_ variables are set', () => {
process.env = {
REGULAR_ENV_VAR: 'value',
N8N_OTHER_PREFIX: 'value',
};
const service = createMockService();
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
const result = collectEnvFeatureFlags();
expect(result).toEqual({});
});
it('should filter out undefined environment variable values', () => {
process.env = {
N8N_ENV_FEAT_DEFINED_FLAG: 'true',
N8N_ENV_FEAT_UNDEFINED_FLAG: undefined,
};
const service = createMockService();
const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service);
const result = collectEnvFeatureFlags();
expect(result).toEqual({
N8N_ENV_FEAT_DEFINED_FLAG: 'true',
// N8N_ENV_FEAT_UNDEFINED_FLAG should be filtered out
});
});
});
describe('settings integration', () => {
it('should include envFeatureFlags in initial settings', () => {
process.env = {
N8N_ENV_FEAT_INIT_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
};
const service = createMockService();
expect(service.settings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INIT_FLAG: 'true',
N8N_ENV_FEAT_ANOTHER_FLAG: 'false',
});
});
it('should refresh envFeatureFlags when getSettings is called', () => {
process.env = {
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
};
const service = createMockService();
// Verify initial state
expect(service.settings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INITIAL_FLAG: 'true',
});
// Change environment
process.env = {
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
N8N_ENV_FEAT_NEW_FLAG: 'true',
};
// getSettings should refresh the flags
const settings = service.getSettings();
expect(settings.envFeatureFlags).toEqual({
N8N_ENV_FEAT_INITIAL_FLAG: 'false',
N8N_ENV_FEAT_NEW_FLAG: 'true',
});
});
});
});
});

View File

@@ -1,4 +1,4 @@
import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types';
import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig, SecurityConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants';
@@ -66,6 +66,18 @@ export class FrontendService {
}
}
private collectEnvFeatureFlags(): N8nEnvFeatFlags {
const envFeatureFlags: N8nEnvFeatFlags = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('N8N_ENV_FEAT_') && value !== undefined) {
envFeatureFlags[key as keyof N8nEnvFeatFlags] = value;
}
}
return envFeatureFlags;
}
private initSettings() {
const instanceBaseUrl = this.urlService.getInstanceBaseUrl();
const restEndpoint = this.globalConfig.endpoints.rest;
@@ -260,6 +272,7 @@ export class FrontendService {
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
},
activeModules: this.moduleRegistry.getActiveModules(),
envFeatureFlags: this.collectEnvFeatureFlags(),
};
}
@@ -404,6 +417,9 @@ export class FrontendService {
// Refresh evaluation settings
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();
// Refresh environment feature flags
this.settings.envFeatureFlags = this.collectEnvFeatureFlags();
return this.settings;
}

View File

@@ -153,4 +153,5 @@ export const defaultSettings: FrontendSettings = {
quota: 0,
},
activeModules: [],
envFeatureFlags: {},
};

View File

@@ -0,0 +1,163 @@
import { createTestingPinia } from '@pinia/testing';
import type { FrontendSettings, N8nEnvFeatFlags, N8nEnvFeatFlagValue } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { useSettingsStore } from '@/stores/settings.store';
const renderComponent = createComponentRenderer(EnvFeatureFlag);
describe('EnvFeatureFlag', () => {
let settingsStore: MockedStore<typeof useSettingsStore>;
const originalEnv = { ...import.meta.env };
beforeEach(() => {
Object.keys(import.meta.env).forEach((key) => {
if (key.startsWith('N8N_ENV_FEAT_')) {
delete (import.meta.env as N8nEnvFeatFlags)[key as keyof N8nEnvFeatFlags];
}
});
createTestingPinia();
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = {
envFeatureFlags: {},
} as FrontendSettings;
});
afterEach(() => {
Object.assign(import.meta.env, originalEnv);
});
test.each<[N8nEnvFeatFlagValue, Uppercase<string>, boolean]>([
// Truthy values that should render content
['true', 'TEST_FLAG', true],
['enabled', 'TEST_FLAG', true],
['yes', 'TEST_FLAG', true],
['1', 'TEST_FLAG', true],
['on', 'TEST_FLAG', true],
[true, 'TEST_FLAG', true],
// Falsy values that should not render content
['false', 'TEST_FLAG', false],
[false, 'TEST_FLAG', false],
['', 'TEST_FLAG', false],
[undefined, 'TEST_FLAG', false],
[0, 'TEST_FLAG', false],
])(
'should %s render slot content when feature flag value is %s',
(value, flagName, shouldRender) => {
const envKey: keyof N8nEnvFeatFlags = `N8N_ENV_FEAT_${flagName}`;
settingsStore.settings.envFeatureFlags = {
[envKey]: value,
};
const { container } = renderComponent({
props: {
name: flagName,
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
if (shouldRender) {
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
} else {
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
}
},
);
it('should work with different flag names', () => {
settingsStore.settings.envFeatureFlags = {
N8N_ENV_FEAT_WORKFLOW_DIFFS: 'true',
N8N_ENV_FEAT_ANOTHER_FEATURE: 'false',
};
const { container: container1 } = renderComponent({
props: {
name: 'WORKFLOW_DIFFS',
},
slots: {
default: '<div data-testid="feature-1">Feature 1</div>',
},
});
const { container: container2 } = renderComponent({
props: {
name: 'ANOTHER_FEATURE',
},
slots: {
default: '<div data-testid="feature-2">Feature 2</div>',
},
});
expect(container1.querySelector('[data-testid="feature-1"]')).toBeTruthy();
expect(container2.querySelector('[data-testid="feature-2"]')).toBeNull();
});
describe('runtime vs build-time priority', () => {
it('should prioritize runtime settings over build-time env vars', () => {
// Set build-time env var
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
// Set runtime setting to override
settingsStore.settings.envFeatureFlags = {
N8N_ENV_FEAT_TEST_FLAG: 'false',
};
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should use runtime value (false) over build-time value (true)
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
});
it('should fallback to build-time env vars when runtime settings are not available', () => {
// Set build-time env var
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
// Runtime settings are empty
settingsStore.settings.envFeatureFlags = {};
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should use build-time value
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
});
it('should return false when neither runtime nor build-time values are set', () => {
// No runtime setting
settingsStore.settings.envFeatureFlags = {};
// No build-time env var (already cleared in beforeEach)
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should default to false
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
});
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useEnvFeatureFlag } from '@/features/env-feature-flag/useEnvFeatureFlag';
/*
EnvFeatureFlag conditionally renders content based on environment variable based feature flags
Environment variable feature flags are defined in form of `N8N_ENV_FEAT_<FEATURE_NAME>`
The component's name property should be in uppercase and match the environment variable name without the prefix
Usage example: <EnvFeatureFlag name="FEATURE_NAME"> Feature content </EnvFeatureFlag>
*/
export default defineComponent({
name: 'EnvFeatureFlag',
props: {
name: {
type: String as () => Uppercase<string>,
required: true,
},
},
setup(props, { slots }) {
const envFeatureFlag = useEnvFeatureFlag();
const isEnabled = computed(() => envFeatureFlag.check.value(props.name));
return () => (isEnabled.value && slots.default ? slots.default() : null);
},
});
</script>

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue';
import type { N8nEnvFeatFlags } from '@n8n/api-types';
import { useSettingsStore } from '@/stores/settings.store';
export const useEnvFeatureFlag = () => {
const settingsStore = useSettingsStore();
const check = computed(() => (flag: Uppercase<string>): boolean => {
const key = `N8N_ENV_FEAT_${flag}` as const;
// Settings provided by the backend take precedence over build-time or runtime flags
const settingsProvidedEnvFeatFlag = settingsStore.settings.envFeatureFlags?.[key];
if (settingsProvidedEnvFeatFlag !== undefined) {
return settingsProvidedEnvFeatFlag !== 'false' && !!settingsProvidedEnvFeatFlag;
}
// "Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective."
// See https://vite.dev/guide/env-and-mode.html
const buildTimeValue = (import.meta.env as N8nEnvFeatFlags)[key];
if (buildTimeValue !== undefined) {
return buildTimeValue !== 'false' && !!buildTimeValue;
}
return false;
});
return {
check,
};
};

View File

@@ -141,7 +141,7 @@ export default mergeConfig(
plugins,
resolve: { alias },
base: publicPath,
envPrefix: 'VUE',
envPrefix: ['VUE', 'N8N_ENV_FEAT'],
css: {
preprocessorOptions: {
scss: {