mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-24 12:19:44 +08:00
feat(core): Make user role provisioning available on enterprise (#22166)
This commit is contained in:
@@ -175,7 +175,7 @@ export class LicenseState {
|
||||
}
|
||||
|
||||
isProvisioningLicensed() {
|
||||
return this.isLicensed(['feat:saml', 'feat:oidc', 'feat:ldap']);
|
||||
return this.isLicensed(['feat:saml', 'feat:oidc']);
|
||||
}
|
||||
|
||||
// --------------------
|
||||
|
||||
@@ -83,10 +83,6 @@ export const PERSONALIZED_TEMPLATES_V3 = {
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT = {
|
||||
name: '050_sso_jit_provisioning',
|
||||
};
|
||||
|
||||
export const EXPERIMENTS_TO_TRACK = [
|
||||
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
|
||||
TEMPLATE_ONBOARDING_EXPERIMENT.name,
|
||||
@@ -96,5 +92,4 @@ export const EXPERIMENTS_TO_TRACK = [
|
||||
TEMPLATE_RECO_V2.name,
|
||||
TEMPLATES_DATA_QUALITY_EXPERIMENT.name,
|
||||
READY_TO_RUN_V2_PART2_EXPERIMENT.name,
|
||||
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
|
||||
];
|
||||
|
||||
+2
-19
@@ -5,10 +5,9 @@ import { SupportedProtocols, useSSOStore } from '../sso.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import { ElCheckbox } from 'element-plus';
|
||||
import { N8nActionBox, N8nButton, N8nInput, N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import { N8nButton, N8nInput, N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import UserRoleProvisioningDropdown, {
|
||||
type UserRoleProvisioningSetting,
|
||||
@@ -24,7 +23,6 @@ const ssoStore = useSSOStore();
|
||||
const telemetry = useTelemetry();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
const instanceId = useRootStore().instanceId;
|
||||
|
||||
const savingForm = ref<boolean>(false);
|
||||
@@ -189,16 +187,12 @@ function sendTrackingEventForUserProvisioning() {
|
||||
});
|
||||
}
|
||||
|
||||
const goToUpgrade = () => {
|
||||
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOidcConfig();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="ssoStore.isEnterpriseOidcEnabled">
|
||||
<div>
|
||||
<div :class="$style.group">
|
||||
<label>Redirect URL</label>
|
||||
<CopyInput
|
||||
@@ -298,16 +292,5 @@ onMounted(async () => {
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
<N8nActionBox
|
||||
v-else
|
||||
data-test-id="sso-content-unlicensed"
|
||||
:class="$style.actionBox"
|
||||
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
|
||||
@click:button="goToUpgrade"
|
||||
>
|
||||
<template #heading>
|
||||
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
|
||||
</template>
|
||||
</N8nActionBox>
|
||||
</template>
|
||||
<style lang="scss" module src="../styles/sso-form.module.scss" />
|
||||
|
||||
+2
-20
@@ -6,9 +6,8 @@ import { useI18n } from '@n8n/i18n';
|
||||
import { captureMessage } from '@sentry/vue';
|
||||
|
||||
import { ElCheckbox } from 'element-plus';
|
||||
import { N8nActionBox, N8nButton, N8nInput, N8nRadioButtons } from '@n8n/design-system';
|
||||
import { N8nButton, N8nInput, N8nRadioButtons } from '@n8n/design-system';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { useMessage } from '@/app/composables/useMessage';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import UserRoleProvisioningDropdown, {
|
||||
@@ -25,7 +24,6 @@ const ssoStore = useSSOStore();
|
||||
const telemetry = useTelemetry();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
const instanceId = useRootStore().instanceId;
|
||||
|
||||
const savingForm = ref<boolean>(false);
|
||||
@@ -278,16 +276,12 @@ const validateSamlInput = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goToUpgrade = () => {
|
||||
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSamlConfig();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
|
||||
<div>
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
|
||||
<CopyInput
|
||||
@@ -367,18 +361,6 @@ onMounted(async () => {
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
<N8nActionBox
|
||||
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>
|
||||
</N8nActionBox>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module src="../styles/sso-form.module.scss" />
|
||||
|
||||
+4
-9
@@ -1,13 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants';
|
||||
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
|
||||
|
||||
import { N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import { onMounted } from 'vue';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { useUserRoleProvisioningStore } from '../composables/userRoleProvisioning.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { type SupportedProtocolType } from '../../sso.store';
|
||||
import { useRBACStore } from '@/app/stores/rbac.store';
|
||||
|
||||
export type UserRoleProvisioningSetting =
|
||||
| 'disabled'
|
||||
@@ -21,12 +20,8 @@ const { authProtocol } = defineProps<{
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const posthogStore = usePostHog();
|
||||
const userRoleProvisioningStore = useUserRoleProvisioningStore();
|
||||
|
||||
const isUserRoleProvisioningFeatureEnabled = posthogStore.isFeatureEnabled(
|
||||
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
|
||||
);
|
||||
const canManageUserProvisioning = useRBACStore().hasScope('provisioning:manage');
|
||||
|
||||
const handleUserRoleProvisioningChange = (newValue: UserRoleProvisioningSetting) => {
|
||||
value.value = newValue;
|
||||
@@ -79,11 +74,11 @@ onMounted(async () => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<!-- TODO: also check for 'provisioning:manage' permission scope -->
|
||||
<div v-if="isUserRoleProvisioningFeatureEnabled" :class="$style.group">
|
||||
<div :class="$style.group">
|
||||
<label>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.label') }}</label>
|
||||
<N8nSelect
|
||||
:model-value="value"
|
||||
:disabled="!canManageUserProvisioning"
|
||||
data-test-id="oidc-user-role-provisioning"
|
||||
:class="$style.userRoleProvisioningSelect"
|
||||
@update:model-value="handleUserRoleProvisioningChange"
|
||||
|
||||
+5
-1
@@ -104,7 +104,11 @@ export function useAccessSettingsCsvExport() {
|
||||
for (const project of user.projectRelations) {
|
||||
const projectName = escapeCsvValue(project.name ?? '');
|
||||
const projectId = escapeCsvValue(project.id ?? '');
|
||||
const projectRole = escapeCsvValue(project.role ?? '');
|
||||
// In our backend, project roles are stored like this: project:viewer
|
||||
// But to make the configuration of project role provisioning easier,
|
||||
// we omit this part of the role value in the SSO config.
|
||||
const roleValueForProvisioning = project.role.split(':')[1] ?? project.role;
|
||||
const projectRole = escapeCsvValue(roleValueForProvisioning);
|
||||
csvRows.push(`${email},${projectName},${projectId},${projectRole}`);
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -1,7 +1,5 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { useUserRoleProvisioningStore } from './userRoleProvisioning.store';
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants/experiments';
|
||||
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
|
||||
import { type UserRoleProvisioningSetting } from '../components/UserRoleProvisioningDropdown.vue';
|
||||
|
||||
@@ -12,7 +10,6 @@ export function useUserRoleProvisioningForm(
|
||||
userRoleProvisioning: Ref<UserRoleProvisioningSetting>,
|
||||
) {
|
||||
const provisioningStore = useUserRoleProvisioningStore();
|
||||
const posthogStore = usePostHog();
|
||||
|
||||
const getUserRoleProvisioningValueFromConfig = (
|
||||
config?: ProvisioningConfig,
|
||||
@@ -51,9 +48,6 @@ export function useUserRoleProvisioningForm(
|
||||
};
|
||||
|
||||
const isUserRoleProvisioningChanged = (): boolean => {
|
||||
if (!posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getUserRoleProvisioningValueFromConfig(provisioningStore.provisioningConfig) !==
|
||||
userRoleProvisioning.value
|
||||
|
||||
@@ -392,10 +392,10 @@ describe('SettingsSso View', () => {
|
||||
});
|
||||
ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, loginEnabled: true });
|
||||
|
||||
const { getByTestId, getByRole } = renderView();
|
||||
const { getByTestId, getByRole, getAllByRole } = renderView();
|
||||
|
||||
// Set authProtocol component ref to OIDC
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
expect(protocolSelect).toBeInTheDocument();
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
@@ -463,11 +463,11 @@ describe('SettingsSso View', () => {
|
||||
discoveryEndpoint: '',
|
||||
});
|
||||
|
||||
const { getByTestId, getByRole } = renderView();
|
||||
const { getByTestId, getByRole, getAllByRole } = renderView();
|
||||
showError.mockClear();
|
||||
|
||||
// Set authProtocol component ref to OIDC
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
expect(protocolSelect).toBeInTheDocument();
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
@@ -519,10 +519,10 @@ describe('SettingsSso View', () => {
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
|
||||
|
||||
const { getByRole } = renderView();
|
||||
const { getByRole, getAllByRole } = renderView();
|
||||
|
||||
// Change protocol selection in dropdown
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
const dropdown = await waitFor(() => getByRole('listbox'));
|
||||
@@ -542,10 +542,10 @@ describe('SettingsSso View', () => {
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
|
||||
|
||||
const { getByRole, getByTestId } = renderView();
|
||||
const { getByRole, getByTestId, getAllByRole } = renderView();
|
||||
|
||||
// Change to SAML protocol in dropdown
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
const dropdown = await waitFor(() => getByRole('listbox'));
|
||||
@@ -578,10 +578,10 @@ describe('SettingsSso View', () => {
|
||||
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
|
||||
ssoStore.saveOidcConfig.mockResolvedValue(oidcConfig);
|
||||
|
||||
const { getByRole, getByTestId } = renderView();
|
||||
const { getByRole, getByTestId, getAllByRole } = renderView();
|
||||
|
||||
// Change to OIDC protocol in dropdown
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
const dropdown = await waitFor(() => getByRole('listbox'));
|
||||
@@ -641,14 +641,14 @@ describe('SettingsSso View', () => {
|
||||
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
|
||||
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
|
||||
|
||||
const { getByRole, getByTestId, queryByTestId } = renderView();
|
||||
const { getByRole, getAllByRole, getByTestId, queryByTestId } = renderView();
|
||||
|
||||
// Initially should show SAML content (matching store)
|
||||
expect(getByTestId('sso-provider-url')).toBeVisible();
|
||||
expect(queryByTestId('oidc-discovery-endpoint')).not.toBeInTheDocument();
|
||||
|
||||
// Change to OIDC in dropdown (local state only)
|
||||
const protocolSelect = getByRole('combobox');
|
||||
const protocolSelect = getAllByRole('combobox')[0];
|
||||
await userEvent.click(protocolSelect);
|
||||
|
||||
const dropdown = await waitFor(() => getByRole('listbox'));
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
|
||||
import { useSSOStore, SupportedProtocols, type SupportedProtocolType } from '../sso.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { N8nHeading, N8nInfoTip, N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import { N8nActionBox, N8nHeading, N8nInfoTip, N8nOption, N8nSelect } from '@n8n/design-system';
|
||||
import SamlSettingsForm from '../components/SamlSettingsForm.vue';
|
||||
import OidcSettingsForm from '../components/OidcSettingsForm.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const ssoStore = useSSOStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
|
||||
const options = computed(() => {
|
||||
return [
|
||||
@@ -27,11 +29,19 @@ const options = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const hasAnySsoEnabled = computed(
|
||||
() => ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled,
|
||||
);
|
||||
|
||||
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
|
||||
function onAuthProtocolUpdated(value: SupportedProtocolType) {
|
||||
authProtocol.value = value;
|
||||
}
|
||||
|
||||
const goToUpgrade = () => {
|
||||
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
documentTitle.set(i18n.baseText('settings.sso.title'));
|
||||
ssoStore.initializeSelectedProtocol();
|
||||
@@ -50,11 +60,7 @@ onMounted(() => {
|
||||
{{ i18n.baseText('settings.sso.info.link') }}
|
||||
</a>
|
||||
</N8nInfoTip>
|
||||
<div
|
||||
v-if="ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled"
|
||||
data-test-id="sso-auth-protocol-select"
|
||||
:class="shared.group"
|
||||
>
|
||||
<div v-if="hasAnySsoEnabled" data-test-id="sso-auth-protocol-select" :class="shared.group">
|
||||
<label>Select Authentication Protocol</label>
|
||||
<div>
|
||||
<N8nSelect
|
||||
@@ -75,12 +81,30 @@ onMounted(() => {
|
||||
</N8nSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="authProtocol === SupportedProtocols.SAML">
|
||||
<div
|
||||
v-if="ssoStore.isEnterpriseSamlEnabled && authProtocol === SupportedProtocols.SAML"
|
||||
data-test-id="sso-content-licensed"
|
||||
>
|
||||
<SamlSettingsForm />
|
||||
</div>
|
||||
<div v-if="authProtocol === SupportedProtocols.OIDC">
|
||||
<div
|
||||
v-if="ssoStore.isEnterpriseOidcEnabled && authProtocol === SupportedProtocols.OIDC"
|
||||
data-test-id="sso-content-licensed"
|
||||
>
|
||||
<OidcSettingsForm />
|
||||
</div>
|
||||
<N8nActionBox
|
||||
v-if="!hasAnySsoEnabled"
|
||||
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>
|
||||
</N8nActionBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -90,4 +114,8 @@ onMounted(() => {
|
||||
.heading {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.actionBox {
|
||||
margin-top: var(--spacing--lg);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user