fix(editor): Gate dynamic credentials UI behind license check (#25464)

This commit is contained in:
Csaba Tuncsik
2026-02-10 07:29:27 +01:00
committed by GitHub
parent 8ac4ff3849
commit ed099ad9f9
14 changed files with 98 additions and 44 deletions
@@ -118,7 +118,9 @@ describe('WorkflowCard', () => {
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
activeModules: ['dynamic-credentials'],
} as unknown as FrontendSettings;
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
});
@@ -47,7 +47,7 @@ import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate';
import { createEventBus } from '@n8n/utils/event-bus';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@@ -109,7 +109,7 @@ const router = useRouter();
const route = useRoute();
const telemetry = useTelemetry();
const mcp = useMcp();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const uiStore = useUIStore();
const usersStore = useUsersStore();
@@ -293,10 +293,6 @@ const isWorkflowPublished = computed(() => {
return props.data.activeVersionId !== null;
});
const isDynamicCredentialsEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const hasDynamicCredentials = computed(() => {
return isDynamicCredentialsEnabled.value && props.data.hasResolvableCredentials;
});
@@ -80,8 +80,10 @@ describe('WorkflowSettingsVue', () => {
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
activeModules: ['dynamic-credentials'],
releaseChannel: 'stable',
});
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.workflowId = '1';
// Populate workflowsById to mark workflow as existing (not new)
@@ -45,8 +45,8 @@ import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
import { useGlobalLinkActions } from '@/app/composables/useGlobalLinkActions';
import { useNodeCreatorStore } from '@/features/shared/nodeCreator/nodeCreator.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useCredentialResolvers } from '@/features/resolvers/composables/useCredentialResolvers';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { ElCol, ElRow, ElSwitch } from 'element-plus';
@@ -58,7 +58,7 @@ const modalBus = createEventBus();
const telemetry = useTelemetry();
const { isEligibleForMcpAccess, trackMcpAccessEnabledForWorkflow, mcpTriggerMap } = useMcp();
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const { isEnabled: isCredentialResolverEnabled } = useDynamicCredentials();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
@@ -144,9 +144,6 @@ const executionLogic = computed(() => {
const isMCPEnabled = computed(
() => settingsStore.isModuleActive('mcp') && settingsStore.moduleSettings.mcp?.mcpAccessEnabled,
);
const isCredentialResolverEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const readOnlyEnv = computed(
() => sourceControlStore.preferences.branchReadOnly || collaborationStore.shouldBeReadOnly,
);
@@ -24,6 +24,7 @@ import { useRecentResources } from '@/features/shared/commandBar/composables/use
import { usePostHog } from '@/app/stores/posthog.store';
import { TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
@@ -679,8 +680,8 @@ export const routes: RouteRecordRaw[] = [
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
const { check } = useEnvFeatureFlag();
return check.value('DYNAMIC_CREDENTIALS');
const { isEnabled } = useDynamicCredentials();
return isEnabled.value;
},
},
telemetry: {
@@ -37,7 +37,9 @@ describe('CredentialCard', () => {
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
activeModules: ['dynamic-credentials'],
} as unknown as FrontendSettings;
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
});
it('should render name and home project name', () => {
@@ -14,7 +14,7 @@ import ProjectCardBadge from '@/features/collaboration/projects/components/Proje
import { useI18n } from '@n8n/i18n';
import { ResourceType } from '@/features/collaboration/projects/projects.utils';
import type { CredentialsResource } from '@/Interface';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import {
N8nActionToggle,
@@ -51,11 +51,7 @@ const message = useMessage();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const projectsStore = useProjectsStore();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const isDynamicCredentialsEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
const credentialType = computed(() =>
@@ -65,7 +65,7 @@ import {
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { setParameterValue } from '@/app/utils/parameterUtils';
import get from 'lodash/get';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
type Props = {
modalName: string;
@@ -92,7 +92,7 @@ const i18n = useI18n();
const telemetry = useTelemetry();
const router = useRouter();
const rootStore = useRootStore();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const activeTab = ref('connection');
const authError = ref('');
@@ -352,10 +352,6 @@ const homeProject = computed(() => {
return currentProject ?? personalProject;
});
const isDynamicCredentialsEnabled = computed<boolean>(() => {
return checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS');
});
const isNewCredential = computed(() => props.mode === 'new' && !credentialId.value);
onMounted(async () => {
@@ -97,7 +97,9 @@ describe('NodeCredentials', () => {
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: true,
},
activeModules: ['dynamic-credentials'],
} as unknown as FrontendSettings;
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
beforeAll(() => {
credentialsStore.state.credentialTypes = {
@@ -33,7 +33,7 @@ import {
import { isEmpty } from '@/app/utils/typesUtils';
import { getResourcePermissions } from '@n8n/permissions';
import { useNodeCredentialOptions } from '../composables/useNodeCredentialOptions';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import {
N8nBadge,
@@ -80,7 +80,7 @@ const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const workflowState = injectWorkflowState();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const canCreateCredentials = computed(
() =>
@@ -130,10 +130,6 @@ const selected = computed<Record<string, INodeCredentialsDetails>>(
() => props.node.credentials ?? {},
);
const isDynamicCredentialsEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const hasWorkflowResolver = computed(() => {
return !!workflowsStore.workflowSettings?.credentialResolverId;
});
@@ -0,0 +1,53 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useDynamicCredentials } from './useDynamicCredentials';
import { useSettingsStore } from '@/app/stores/settings.store';
import type { FrontendSettings } from '@n8n/api-types';
describe('useDynamicCredentials', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
const setup = ({
moduleActive,
featureFlag,
}: {
moduleActive: boolean;
featureFlag: boolean;
}) => {
const pinia = createTestingPinia();
setActivePinia(pinia);
settingsStore = useSettingsStore();
settingsStore.settings = {
envFeatureFlags: {
N8N_ENV_FEAT_DYNAMIC_CREDENTIALS: featureFlag,
},
activeModules: moduleActive ? ['dynamic-credentials'] : [],
} as unknown as FrontendSettings;
vi.spyOn(settingsStore, 'isModuleActive').mockImplementation(
(name: string) => moduleActive && name === 'dynamic-credentials',
);
return useDynamicCredentials();
};
it('should be enabled when both module is active and feature flag is on', () => {
const { isEnabled } = setup({ moduleActive: true, featureFlag: true });
expect(isEnabled.value).toBe(true);
});
it('should be disabled when module is not active', () => {
const { isEnabled } = setup({ moduleActive: false, featureFlag: true });
expect(isEnabled.value).toBe(false);
});
it('should be disabled when feature flag is off', () => {
const { isEnabled } = setup({ moduleActive: true, featureFlag: false });
expect(isEnabled.value).toBe(false);
});
it('should be disabled when both module is not active and feature flag is off', () => {
const { isEnabled } = setup({ moduleActive: false, featureFlag: false });
expect(isEnabled.value).toBe(false);
});
});
@@ -0,0 +1,14 @@
import { computed } from 'vue';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
export const useDynamicCredentials = () => {
const settingsStore = useSettingsStore();
const { check } = useEnvFeatureFlag();
const isEnabled = computed(
() => settingsStore.isModuleActive('dynamic-credentials') && check.value('DYNAMIC_CREDENTIALS'),
);
return { isEnabled };
};
@@ -9,23 +9,24 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { createTestingPinia } from '@pinia/testing';
import CanvasNodeSettingsIcons from './CanvasNodeSettingsIcons.vue';
import type { INode } from 'n8n-workflow';
import { computed } from 'vue';
vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
useEnvFeatureFlag: vi.fn(),
vi.mock('@/features/resolvers/composables/useDynamicCredentials', () => ({
useDynamicCredentials: vi.fn(),
}));
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
const mockedUseEnvFeatureFlag = vi.mocked(useEnvFeatureFlag);
const mockedUseDynamicCredentials = vi.mocked(useDynamicCredentials);
const renderComponent = createComponentRenderer(CanvasNodeSettingsIcons, {
pinia: createTestingPinia(),
});
const mockFeatureFlag = (enabled: boolean) => {
mockedUseEnvFeatureFlag.mockReturnValue({
check: { value: (flag: string) => enabled && flag === 'DYNAMIC_CREDENTIALS' },
} as unknown as ReturnType<typeof useEnvFeatureFlag>);
mockedUseDynamicCredentials.mockReturnValue({
isEnabled: computed(() => enabled),
} as ReturnType<typeof useDynamicCredentials>);
};
describe('CanvasNodeSettingsIcons', () => {
@@ -4,18 +4,14 @@ import { useCanvasNode } from '../../../../../composables/useCanvasNode';
import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
const { name } = useCanvasNode();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const { check: checkEnvFeatureFlag } = useEnvFeatureFlag();
const isDynamicCredentialsEnabled = computed(() =>
checkEnvFeatureFlag.value('DYNAMIC_CREDENTIALS'),
);
const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials();
const node = computed(() => workflowsStore.workflowObject.getNode(name.value));
const size = 'medium';