chore(editor): Gate personal space policy settings behind license feature (#25527)

This commit is contained in:
Andreas Fitzek 2026-02-12 11:12:18 +01:00 committed by GitHub
parent 88017640c4
commit cfca041d0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 164 additions and 19 deletions

View File

@ -60,6 +60,7 @@ export interface IEnterpriseSettings {
};
};
customRoles: boolean;
personalSpacePolicy: boolean;
}
export interface FrontendSettings {

View File

@ -62,6 +62,10 @@ export class LicenseState {
return this.isLicensed(LICENSE_FEATURES.DYNAMIC_CREDENTIALS);
}
isPersonalSpacePolicyLicensed() {
return this.isLicensed(LICENSE_FEATURES.PERSONAL_SPACE_POLICY);
}
isSharingLicensed() {
return this.isLicensed('feat:sharing');
}

View File

@ -40,6 +40,7 @@ export const LICENSE_FEATURES = {
CUSTOM_ROLES: 'feat:customRoles',
AI_BUILDER: 'feat:aiBuilder',
DYNAMIC_CREDENTIALS: 'feat:dynamicCredentials',
PERSONAL_SPACE_POLICY: 'feat:personalSpacePolicy',
} as const;
export const LICENSE_QUOTAS = {

View File

@ -122,6 +122,7 @@ export class E2EController {
[LICENSE_FEATURES.NAMED_VERSIONS]: false,
[LICENSE_FEATURES.CUSTOM_ROLES]: false,
[LICENSE_FEATURES.AI_BUILDER]: false,
[LICENSE_FEATURES.PERSONAL_SPACE_POLICY]: false,
};
private static readonly numericFeaturesDefaults: Record<NumericLicenseFeature, number> = {

View File

@ -1,6 +1,6 @@
import { UpdateSecuritySettingsDto } from '@n8n/api-types';
import { type AuthenticatedRequest } from '@n8n/db';
import { Body, Get, GlobalScope, Post, RestController } from '@n8n/decorators';
import { Body, Get, GlobalScope, Licensed, Post, RestController } from '@n8n/decorators';
import {
PERSONAL_SPACE_PUBLISHING_SETTING,
PERSONAL_SPACE_SHARING_SETTING,
@ -17,6 +17,7 @@ export class SecuritySettingsController {
private readonly eventService: EventService,
) {}
@Licensed('feat:personalSpacePolicy')
@GlobalScope('securitySettings:manage')
@Get('/')
async getSecuritySettings(_req: AuthenticatedRequest, _res: Response) {
@ -39,6 +40,7 @@ export class SecuritySettingsController {
};
}
@Licensed('feat:personalSpacePolicy')
@GlobalScope('securitySettings:manage')
@Post('/')
async updateSecuritySettings(

View File

@ -318,6 +318,7 @@ export class FrontendService {
},
},
customRoles: false,
personalSpacePolicy: false,
},
mfa: {
enabled: false,
@ -460,6 +461,7 @@ export class FrontendService {
workflowDiffs: this.licenseState.isWorkflowDiffsLicensed(),
namedVersions: this.license.isLicensed(LICENSE_FEATURES.NAMED_VERSIONS),
customRoles: this.licenseState.isCustomRolesLicensed(),
personalSpacePolicy: this.licenseState.isPersonalSpacePolicyLicensed(),
});
if (this.license.isLdapEnabled()) {

View File

@ -23,9 +23,15 @@ describe('SecuritySettingsController', () => {
beforeEach(() => {
jest.clearAllMocks();
testServer.license.enable('feat:personalSpacePolicy');
});
describe('GET /settings/security', () => {
it('should return 403 when personalSpacePolicy license is not active', async () => {
testServer.license.disable('feat:personalSpacePolicy');
await ownerAgent.get('/settings/security').expect(403);
});
it('should return security settings and all counts', async () => {
securitySettingsService.arePersonalSpaceSettingsEnabled.mockResolvedValue({
personalSpacePublishing: true,
@ -78,6 +84,14 @@ describe('SecuritySettingsController', () => {
});
describe('POST /settings/security', () => {
it('should return 403 when personalSpacePolicy license is not active', async () => {
testServer.license.disable('feat:personalSpacePolicy');
await ownerAgent
.post('/settings/security')
.send({ personalSpacePublishing: true })
.expect(403);
});
it('should update only personalSpacePublishing when only that is set in body', async () => {
securitySettingsService.setPersonalSpaceSetting.mockResolvedValue(undefined);

View File

@ -3995,6 +3995,8 @@
"settings.security.personalSpace.sharing.confirmMessage.disable.message": "This will prevent sharing workflows and credentials from personal spaces going forwards. Existing shares will remain in place.",
"settings.security.personalSpace.sharing.existingCount.label": "Existing shares",
"settings.security.personalSpace.sharing.existingCount.value": "{workflowCount} workflows, {credentialCount} credentials",
"settings.security.personalSpace.unlicensed_tooltip": "Upgrade to a plan that includes personal space policies to manage this setting. {action}",
"settings.security.personalSpace.unlicensed_tooltip.link": "Upgrade now",
"settings.sso": "SSO",
"settings.sso.title": "Single Sign On",
"settings.sso.subtitle": "SAML 2.0 Configuration",

View File

@ -949,6 +949,7 @@ export type EnterpriseEditionFeatureKey =
| 'EnforceMFA'
| 'NamedVersions'
| 'Provisioning'
| 'PersonalSpacePolicy'
| 'CustomRoles';
export type EnterpriseEditionFeatureValue = keyof Omit<FrontendSettings['enterprise'], 'projects'>;

View File

@ -51,6 +51,7 @@ export const defaultSettings: FrontendSettings = {
},
},
customRoles: false,
personalSpacePolicy: false,
},
executionMode: 'regular',
isMultiMain: false,

View File

@ -278,6 +278,7 @@ export function createMockEnterpriseSettings(
},
},
customRoles: false,
personalSpacePolicy: false,
...overrides, // Override with any passed properties
};
}

View File

@ -21,5 +21,6 @@ export const EnterpriseEditionFeature: Record<
ApiKeyScopes: 'apiKeyScopes',
NamedVersions: 'namedVersions',
Provisioning: 'provisioning',
PersonalSpacePolicy: 'personalSpacePolicy',
CustomRoles: 'customRoles',
};

View File

@ -136,6 +136,7 @@ describe('CredentialSharing.ee', () => {
},
},
customRoles: false,
personalSpacePolicy: false,
});
});
@ -279,6 +280,7 @@ describe('CredentialSharing.ee', () => {
},
},
customRoles: false,
personalSpacePolicy: false,
});
const credential = createCredential();

View File

@ -62,6 +62,7 @@ describe('SecuritySettings', () => {
settingsStore.isMFAEnforced = false;
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.EnforceMFA] = true;
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.PersonalSpacePolicy] = true;
usersStore.updateEnforceMfa = vi.fn().mockResolvedValue(undefined);
// Enable PERSONAL_SECURITY_SETTINGS env feature flag for Personal Space section
@ -361,4 +362,58 @@ describe('SecuritySettings', () => {
expect(showError).toHaveBeenCalledWith(expect.any(Error), expect.any(String));
});
});
describe('when personalSpacePolicy feature is not licensed', () => {
beforeEach(() => {
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.PersonalSpacePolicy] =
false;
});
it('should show upgrade badges on sharing and publishing titles', async () => {
const { getAllByText, getByTestId } = renderView();
await waitFor(() => {
expect(getByTestId('security-personal-space-sharing-toggle')).toBeInTheDocument();
});
// Two upgrade badges for sharing + publishing (plus one for MFA if unlicensed)
const upgradeBadges = getAllByText('Upgrade');
expect(upgradeBadges.length).toBeGreaterThanOrEqual(2);
});
it('should render disabled sharing toggle when unlicensed', async () => {
const { getByTestId } = renderView();
await waitFor(() => {
expect(getByTestId('security-personal-space-sharing-toggle')).toBeInTheDocument();
});
const sharingToggle = getByTestId('security-personal-space-sharing-toggle');
expect(sharingToggle).toHaveClass('is-disabled');
});
it('should render disabled publishing toggle when unlicensed', async () => {
const { getByTestId } = renderView();
await waitFor(() => {
expect(getByTestId('security-personal-space-publishing-toggle')).toBeInTheDocument();
});
const publishingToggle = getByTestId('security-personal-space-publishing-toggle');
expect(publishingToggle).toHaveClass('is-disabled');
});
it('should not call updateSecuritySettings when clicking disabled toggle', async () => {
const { getByTestId } = renderView();
await waitFor(() => {
expect(getByTestId('security-personal-space-sharing-toggle')).toBeInTheDocument();
});
const sharingToggle = getByTestId('security-personal-space-sharing-toggle');
await userEvent.click(sharingToggle);
expect(updateSecuritySettings).not.toHaveBeenCalled();
});
});
});

View File

@ -25,12 +25,17 @@ const { showToast, showError } = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
const mfaTooltipKey = 'settings.personal.mfa.enforce.unlicensed_tooltip';
const personalSpaceTooltipKey = 'settings.security.personalSpace.unlicensed_tooltip';
const isEnforceMFAEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.EnforceMFA],
);
const isPersonalSpacePolicyLicensed = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.PersonalSpacePolicy],
);
async function onUpdateMfaEnforced(value: string | number | boolean) {
const boolValue = typeof value === 'boolean' ? value : Boolean(value);
try {
@ -188,7 +193,7 @@ const sharingCountText = computed(() => {
:disabled="true"
/>
<template #content>
<I18nT :keypath="tooltipKey" tag="span" scope="global">
<I18nT :keypath="mfaTooltipKey" tag="span" scope="global">
<template #action>
<a @click="goToUpgrade">
{{ i18n.baseText('settings.personal.mfa.enforce.unlicensed_tooltip.link') }}
@ -211,20 +216,46 @@ const sharingCountText = computed(() => {
<div :class="$style.settingsSection">
<div :class="$style.settingsContainer">
<div :class="$style.settingsContainerInfo">
<N8nText :bold="true">
{{ i18n.baseText('settings.security.personalSpace.sharing.title') }}
<N8nText :bold="true"
>{{ i18n.baseText('settings.security.personalSpace.sharing.title') }}
<N8nBadge v-if="!isPersonalSpacePolicyLicensed" class="ml-4xs">{{
i18n.baseText('generic.upgrade')
}}</N8nBadge>
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.security.personalSpace.sharing.description') }}
</N8nText>
</div>
<div :class="$style.settingsContainerAction">
<ElSwitch
v-model="personalSpaceSharing"
:loading="isLoading"
size="large"
data-test-id="security-personal-space-sharing-toggle"
/>
<EnterpriseEdition :features="[EnterpriseEditionFeature.PersonalSpacePolicy]">
<ElSwitch
v-model="personalSpaceSharing"
:loading="isLoading"
size="large"
data-test-id="security-personal-space-sharing-toggle"
/>
<template #fallback>
<N8nTooltip>
<ElSwitch
:model-value="false"
size="large"
:disabled="true"
data-test-id="security-personal-space-sharing-toggle"
/>
<template #content>
<I18nT :keypath="personalSpaceTooltipKey" tag="span" scope="global">
<template #action>
<a @click="goToUpgrade">
{{
i18n.baseText('settings.security.personalSpace.unlicensed_tooltip.link')
}}
</a>
</template>
</I18nT>
</template>
</N8nTooltip>
</template>
</EnterpriseEdition>
</div>
</div>
<div :class="$style.settingsCountRow" data-test-id="security-sharing-count">
@ -240,20 +271,46 @@ const sharingCountText = computed(() => {
<div :class="$style.settingsSection">
<div :class="$style.settingsContainer">
<div :class="$style.settingsContainerInfo">
<N8nText :bold="true">
{{ i18n.baseText('settings.security.personalSpace.publishing.title') }}
<N8nText :bold="true"
>{{ i18n.baseText('settings.security.personalSpace.publishing.title') }}
<N8nBadge v-if="!isPersonalSpacePolicyLicensed" class="ml-4xs">{{
i18n.baseText('generic.upgrade')
}}</N8nBadge>
</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('settings.security.personalSpace.publishing.description') }}
</N8nText>
</div>
<div :class="$style.settingsContainerAction">
<ElSwitch
v-model="personalSpacePublishing"
:loading="isLoading"
size="large"
data-test-id="security-personal-space-publishing-toggle"
/>
<EnterpriseEdition :features="[EnterpriseEditionFeature.PersonalSpacePolicy]">
<ElSwitch
v-model="personalSpacePublishing"
:loading="isLoading"
size="large"
data-test-id="security-personal-space-publishing-toggle"
/>
<template #fallback>
<N8nTooltip>
<ElSwitch
:model-value="false"
size="large"
:disabled="true"
data-test-id="security-personal-space-publishing-toggle"
/>
<template #content>
<I18nT :keypath="personalSpaceTooltipKey" tag="span" scope="global">
<template #action>
<a @click="goToUpgrade">
{{
i18n.baseText('settings.security.personalSpace.unlicensed_tooltip.link')
}}
</a>
</template>
</I18nT>
</template>
</N8nTooltip>
</template>
</EnterpriseEdition>
</div>
</div>
<div :class="$style.settingsCountRow" data-test-id="security-publishing-count">