feat(editor): Add OIDC paywall (#16347)

This commit is contained in:
Raúl Gómez Morales
2025-06-17 15:21:34 +02:00
committed by GitHub
parent ac032418cb
commit 1da3c70507
3 changed files with 373 additions and 378 deletions

View File

@@ -100,6 +100,7 @@
"generic.rename": "Rename",
"generic.missing.permissions": "Missing permissions to perform this action",
"generic.shortcutHint": "Or press",
"generic.upgradeToEnterprise": "Upgrade to Enterprise",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",

View File

@@ -1,15 +1,11 @@
import type { OidcConfigDto, SamlPreferences } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { within, waitFor } from '@testing-library/vue';
import { mockedStore, retry } from '@/__tests__/utils';
import { createPinia, setActivePinia } from 'pinia';
import { mockedStore } from '@/__tests__/utils';
import SettingsSso from '@/views/SettingsSso.vue';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/stores/settings.store';
import userEvent from '@testing-library/user-event';
import { useSSOStore } from '@/stores/sso.store';
import { createComponentRenderer } from '@/__tests__/render';
import { nextTick } from 'vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import type { SamlPreferencesExtractedData } from '@n8n/rest-api-client/api/sso';
@@ -64,6 +60,7 @@ describe('SettingsSso View', () => {
showError.mockReset();
});
describe('SAML', () => {
it('should show upgrade banner when enterprise SAML is disabled', async () => {
const pinia = createTestingPinia();
const ssoStore = mockedStore(useSSOStore);
@@ -139,10 +136,10 @@ describe('SettingsSso View', () => {
const saveButton = getByTestId('sso-save');
expect(saveButton).toBeDisabled();
const urlinput = getByTestId('sso-provider-url');
const urlInput = getByTestId('sso-provider-url');
expect(urlinput).toBeVisible();
await userEvent.type(urlinput, samlConfig.metadataUrl!);
expect(urlInput).toBeVisible();
await userEvent.type(urlInput, samlConfig.metadataUrl as string);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
@@ -156,7 +153,7 @@ describe('SettingsSso View', () => {
expect(telemetryTrack).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ identity_provider: 'metadata', authentication_method: 'saml' }),
expect.objectContaining({ identity_provider: 'metadata' }),
);
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
@@ -214,10 +211,10 @@ describe('SettingsSso View', () => {
const saveButton = getByTestId('sso-save');
expect(saveButton).toBeDisabled();
const urlinput = getByTestId('sso-provider-url');
const urlInput = getByTestId('sso-provider-url');
expect(urlinput).toBeVisible();
await userEvent.type(urlinput, samlConfig.metadata!);
expect(urlInput).toBeVisible();
await userEvent.type(urlInput, samlConfig.metadata as string);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
@@ -286,6 +283,56 @@ describe('SettingsSso View', () => {
expect(toggle.textContent).toContain('Deactivated');
});
});
it('should enable activation checkbox and test button if data is already saved', async () => {
const pinia = createTestingPinia();
const ssoStore = mockedStore(useSSOStore);
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isSamlLoginEnabled = true;
ssoStore.isDefaultAuthenticationOidc = false;
ssoStore.isEnterpriseOidcEnabled = false;
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
const { container, getByTestId, getByRole } = renderView({
pinia,
});
await userEvent.click(getByTestId('radio-button-xml'));
expect(container.querySelector('textarea[name="metadata"]')).toHaveValue(samlConfig.metadata);
expect(getByRole('switch')).toBeEnabled();
expect(getByTestId('sso-test')).toBeEnabled();
});
});
describe('OIDC', () => {
it('should show upgrade banner when enterprise OIDC is disabled', async () => {
const pinia = createTestingPinia();
const ssoStore = mockedStore(useSSOStore);
ssoStore.isDefaultAuthenticationSaml = false;
ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isDefaultAuthenticationOidc = true;
ssoStore.isEnterpriseOidcEnabled = false;
const pageRedirectionHelper = usePageRedirectionHelper();
const { getByTestId } = renderView({ pinia });
await waitFor(() => {
const actionBox = getByTestId('sso-content-unlicensed');
expect(actionBox).toBeInTheDocument();
});
await userEvent.click(
await within(getByTestId('sso-content-unlicensed')).findByText('See plans'),
);
expect(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith('sso', 'upgrade-sso');
});
it('allows user to save OIDC config', async () => {
const pinia = createTestingPinia();
@@ -349,81 +396,4 @@ describe('SettingsSso View', () => {
);
});
});
let pinia: ReturnType<typeof createPinia>;
let ssoStore: ReturnType<typeof useSSOStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let server: ReturnType<typeof setupServer>;
const renderComponent = createComponentRenderer(SettingsSso);
describe('SettingsSso', () => {
beforeAll(() => {
server = setupServer();
});
beforeEach(async () => {
window.open = vi.fn();
pinia = createPinia();
setActivePinia(pinia);
ssoStore = useSSOStore();
settingsStore = useSettingsStore();
await settingsStore.getSettings();
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
it('should render paywall state when there is no license', () => {
const { getByTestId, queryByTestId, queryByRole } = renderComponent({
pinia,
});
expect(queryByRole('checkbox')).not.toBeInTheDocument();
expect(queryByTestId('sso-content-licensed')).not.toBeInTheDocument();
expect(getByTestId('sso-content-unlicensed')).toBeInTheDocument();
});
it('should render licensed content', async () => {
ssoStore.isEnterpriseSamlEnabled = true;
await nextTick();
const { getByTestId, queryByTestId, getByRole } = renderComponent({
pinia,
});
expect(getByRole('switch')).toBeInTheDocument();
expect(getByTestId('sso-content-licensed')).toBeInTheDocument();
expect(queryByTestId('sso-content-unlicensed')).not.toBeInTheDocument();
});
it('should enable activation checkbox and test button if data is already saved', async () => {
await ssoStore.getSamlConfig();
ssoStore.isEnterpriseSamlEnabled = true;
await nextTick();
const { container, getByTestId, getByRole } = renderComponent({
pinia,
});
const xmlRadioButton = getByTestId('radio-button-xml');
await userEvent.click(xmlRadioButton);
await retry(() =>
expect(container.querySelector('textarea[name="metadata"]')).toHaveValue(
'<?xml version="1.0"?>',
),
);
expect(getByRole('switch')).toBeEnabled();
expect(getByTestId('sso-test')).toBeEnabled();
});
});

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup>
import { computed, ref, onMounted } from 'vue';
import { useSSOStore } from '@/stores/sso.store';
import CopyInput from '@/components/CopyInput.vue';
import { useI18n } from '@n8n/i18n';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useMessage } from '@/composables/useMessage';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { MODAL_CONFIRM } from '@/constants';
import { useSSOStore } from '@/stores/sso.store';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, onMounted, ref } from 'vue';
type SupportedProtocolType = (typeof SupportedProtocols)[keyof typeof SupportedProtocols];
@@ -44,6 +44,21 @@ const oidcActivatedLabel = computed(() =>
: i18n.baseText('settings.sso.deactivated'),
);
const options = computed(() => {
return [
{
label: SupportedProtocols.SAML.toUpperCase(),
value: SupportedProtocols.SAML,
},
{
label: ssoStore.isEnterpriseOidcEnabled
? SupportedProtocols.OIDC.toUpperCase()
: `${SupportedProtocols.OIDC.toUpperCase()} (${i18n.baseText('generic.upgradeToEnterprise')})`,
value: SupportedProtocols.OIDC,
},
];
});
const ssoSettingsSaved = ref(false);
const entityId = ref();
@@ -300,26 +315,21 @@ async function onOidcSettingsSave() {
{{ i18n.baseText('settings.sso.info.link') }}
</a>
</n8n-info-tip>
<div
v-if="ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled"
data-test-id="sso-content-licensed"
>
<div :class="$style.group">
<label>Select Authentication Protocol</label>
<div>
<N8nSelect
filterable
:model-value="authProtocol"
data-test-id="sso-auth-protocol-select"
:placeholder="i18n.baseText('parameterInput.select')"
@update:model-value="onAuthProtocolUpdated"
@keydown.stop
>
<N8nOption
v-for="protocol in Object.values(SupportedProtocols)"
:key="protocol"
:value="protocol"
:label="protocol.toUpperCase()"
v-for="{ label, value } in options"
:key="value"
:value="value"
:label="label"
data-test-id="credential-select-option"
>
</N8nOption>
@@ -327,6 +337,7 @@ async function onOidcSettingsSave() {
</div>
</div>
<div v-if="authProtocol === SupportedProtocols.SAML">
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
@@ -350,7 +361,7 @@ async function onOidcSettingsSave() {
<div class="mt-2xs mb-s">
<n8n-radio-buttons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-show="ipsType === IdentityProviderSettingsType.URL">
<div v-if="ipsType === IdentityProviderSettingsType.URL">
<n8n-input
v-model="metadataUrl"
type="text"
@@ -361,7 +372,7 @@ async function onOidcSettingsSave() {
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-show="ipsType === IdentityProviderSettingsType.XML">
<div v-if="ipsType === IdentityProviderSettingsType.XML">
<n8n-input
v-model="metadata"
type="textarea"
@@ -415,7 +426,21 @@ async function onOidcSettingsSave() {
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<n8n-action-box
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:description="i18n.baseText('settings.sso.actionBox.description')"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</n8n-action-box>
</div>
<div v-if="authProtocol === SupportedProtocols.OIDC">
<div v-if="ssoStore.isEnterpriseOidcEnabled">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
@@ -481,12 +506,10 @@ async function onOidcSettingsSave() {
</n8n-button>
</div>
</div>
</div>
<n8n-action-box
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:description="i18n.baseText('settings.sso.actionBox.description')"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
@@ -495,6 +518,7 @@ async function onOidcSettingsSave() {
</template>
</n8n-action-box>
</div>
</div>
</template>
<style lang="scss" module>