feat(core): Move settings for SSO user role provisioning from dedicated page to existing form (#21901)

This commit is contained in:
Konstantin Tieber 2025-11-20 10:40:39 +01:00 committed by GitHub
parent 8720a0e5f3
commit 34039b370b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1244 additions and 1111 deletions

View File

@ -2509,12 +2509,24 @@
"settings.provisioning.scopesProjectsRolesClaimName.help": "The claim name used to provision projects and their roles from Oauth. For SAML / LDAP, this will be the attribute name checked.",
"settings.provisioning.toggle": "Provision instance and project roles",
"settings.provisioning.toggle.help": "Project access can only be defined on external provider. Any existing project access configured in n8n, but not on the provider, will be removed once a user logs in.",
"settings.provisioningConfirmDialog.title": "Enable Just-in-time provisioning (JIT)",
"settings.provisioningConfirmDialog.enable.title": "Enable user role provisioning",
"settings.provisioningConfirmDialog.disable.title": "Disable user role provisioning",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstLine": "When you enable Just-in-time provisioning, your external SSO provider becomes the source of truth for all instance and project roles in n8n.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.one": "If your SSO provider doesn't specify a role for a member, we'll automatically assign the default role: global:member.",
"settings.provisioningConfirmDialog.breakingChangeDescription.list.two": "Any existing instance and project roles in n8n will be replaced by the roles defined in your SSO provider once the user logs in via SSO.",
"settings.provisioningConfirmDialog.breakingChangeRequiredSteps": "To enable you to migrate your current access settings to your SSO provider, download the two CSV files below. This step is mandatory before enabling JIT.",
"settings.provisioningConfirmDialog.button.confirm": "Activate JIT",
"settings.provisioningConfirmDialog.disable.description": "You're switching instance role management back to n8n.",
"settings.provisioningConfirmDialog.disable.whatWillHappen": "What will happen:",
"settings.provisioningConfirmDialog.disable.list.one": "The SSO n8n_instance_role attribute will be ignored.",
"settings.provisioningConfirmDialog.disable.list.two": "Instance roles must be reassigned manually inside n8n.",
"settings.provisioningConfirmDialog.disable.beforeSaving": "Before saving, make sure:",
"settings.provisioningConfirmDialog.disable.checklist.one": "You are ready to reassign instance roles for all users inside n8n.",
"settings.provisioningConfirmDialog.disable.checklist.two": "You understand that role changes made in SSO will no longer be applied.",
"settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to become the source of truth for user role provisioning on this n8n instance.",
"settings.provisioningConfirmDialog.disable.checkbox": "I confirm that I want to no longer provision user roles from my SSO provider.",
"settings.provisioningConfirmDialog.link.docs": "Link to docs",
"settings.provisioningConfirmDialog.button.enable.confirm": "Save and enable",
"settings.provisioningConfirmDialog.button.disable.confirm": "Save and disable",
"settings.provisioningConfirmDialog.button.cancel": "Cancel",
"settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export",
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv",
@ -3442,6 +3454,15 @@
"settings.sso.settings.oidc.prompt.consent": "Consent (Ask the user to consent)",
"settings.sso.settings.oidc.prompt.select_account": "Select Account (Allow the user to select an account)",
"settings.sso.settings.oidc.prompt.create": "Create (Ask the OP to show the registration page first)",
"settings.sso.settings.userRoleProvisioning.label": "User role provisioning",
"settings.sso.settings.userRoleProvisioning.help": "Manage instance and project roles from your SSO provider.",
"settings.sso.settings.userRoleProvisioning.help.linkText": "Link to docs",
"settings.sso.settings.userRoleProvisioning.option.disabled.label": "Disabled",
"settings.sso.settings.userRoleProvisioning.option.disabled.description": "User and project roles are managed inside the n8n settings.",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.label": "Instance role",
"settings.sso.settings.userRoleProvisioning.option.instanceRole.description": "The instance role of a user is configured in the \"n8n_instance_role\" attribute on your SSO provider. If none is set on the SSO provider, the member role is used as fallback.",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label": "Instance and project roles",
"settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description": "The list of projects a user has access to is configured on the \"n8n_projects\" string array attribute on your SSO provider. Project access cannot be granted from within n8n.",
"settings.sso.settings.test": "Test settings",
"settings.sso.settings.save": "Save settings",
"settings.sso.settings.save.activate.title": "Test and activate SAML SSO",

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import { useUserHelpers } from '@/app/composables/useUserHelpers';
import { ABOUT_MODAL_KEY, SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { usePostHog } from '@/app/stores/posthog.store';
import { ABOUT_MODAL_KEY, VIEWS } from '@/app/constants';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
@ -22,7 +21,6 @@ const { canUserAccessRouteByName } = useUserHelpers(router);
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const posthogStore = usePostHog();
const uiStore = useUIStore();
const sidebarMenuItems = computed<IMenuItem[]>(() => {
@ -99,17 +97,6 @@ const sidebarMenuItems = computed<IMenuItem[]>(() => {
available: canUserAccessRouteByName(VIEWS.LDAP_SETTINGS),
route: { to: { name: VIEWS.LDAP_SETTINGS } },
},
{
id: 'settings-provisioning',
icon: 'toolbox',
label: i18n.baseText('settings.provisioning.title'),
position: 'top',
available:
canUserAccessRouteByName(VIEWS.PROVISIONING_SETTINGS) &&
// TODO: comment this back one once posthog experiment is done: settingsStore.isEnterpriseFeatureEnabled.provisioning,
posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name),
route: { to: { name: VIEWS.PROVISIONING_SETTINGS } },
},
{
id: 'settings-workersview',
icon: 'waypoints',

View File

@ -36,7 +36,6 @@ export const enum VIEWS {
LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView',
SSO_SETTINGS = 'SSoSettings',
EXTERNAL_SECRETS_SETTINGS = 'ExternalSecretsSettings',
PROVISIONING_SETTINGS = 'ProvisioningSettings',
SAML_ONBOARDING = 'SamlOnboarding',
SOURCE_CONTROL = 'SourceControl',
MFA_VIEW = 'MfaView',

View File

@ -1,10 +1,9 @@
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import router from '@/app/router';
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT, VIEWS } from '@/app/constants';
import { VIEWS } from '@/app/constants';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/app/stores/settings.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { useRBACStore } from '@/app/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import type { RouteRecordName } from 'vue-router';
@ -136,39 +135,6 @@ describe('router', () => {
10000,
);
// TODO: move these tests cases to the test.each above once experiment is over.
test.each<[string, RouteRecordName, Scope[]]>([
['/settings/provisioning', VIEWS.WORKFLOWS, []],
['/settings/provisioning', VIEWS.PROVISIONING_SETTINGS, ['provisioning:manage']],
])(
'should resolve %s to %s with %s user permissions',
async (path, name, scopes) => {
const rbacStore = useRBACStore();
const posthogStore = usePostHog();
rbacStore.setGlobalScopes(scopes);
posthogStore.overrides[SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name] = true;
await router.push(path);
expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled();
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
// TODO: remove this test once experiment is over
test('should not resolve /settings/provisioning while experiment is not active', async () => {
await router.push('/');
const rbacStore = useRBACStore();
const posthogStore = usePostHog();
rbacStore.setGlobalScopes(['provisioning:manage']);
vi.spyOn(posthogStore, 'isFeatureEnabled').mockReturnValueOnce(false);
await router.push('/settings/provisioning');
expect(router.currentRoute.value.name).toBe(VIEWS.WORKFLOWS);
});
test.each([
[VIEWS.PERSONAL_SETTINGS, true],
[VIEWS.USAGE, false],

View File

@ -11,12 +11,7 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSSOStore } from '@/features/settings/sso/sso.store';
import {
EnterpriseEditionFeature,
VIEWS,
EDITABLE_CANVAS_VIEWS,
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT,
} from '@/app/constants';
import { EnterpriseEditionFeature, VIEWS, EDITABLE_CANVAS_VIEWS } from '@/app/constants';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { middleware } from '@/app/utils/rbac/middleware';
import type { RouterMiddleware } from '@/app/types/router';
@ -27,7 +22,6 @@ import { MfaRequiredError } from '@n8n/rest-api-client';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useRecentResources } from '@/features/shared/commandBar/composables/useRecentResources';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { usePostHog } from '@/app/stores/posthog.store';
const ChangePasswordView = async () =>
await import('@/features/core/auth/views/ChangePasswordView.vue');
@ -83,8 +77,6 @@ const SettingsSourceControl = async () =>
await import('@/features/integrations/sourceControl.ee/views/SettingsSourceControl.vue');
const SettingsExternalSecrets = async () =>
await import('@/features/integrations/externalSecrets.ee/views/SettingsExternalSecrets.vue');
const SettingsProvisioningView = async () =>
await import('@/features/settings/provisioning/views/SettingsProvisioningView.vue');
const WorkerView = async () =>
await import('@/features/settings/orchestration.ee/views/WorkerView.vue');
const WorkflowHistory = async () =>
@ -830,39 +822,6 @@ export const routes: RouteRecordRaw[] = [
},
},
},
{
path: 'provisioning',
name: VIEWS.PROVISIONING_SETTINGS,
components: {
settingsView: SettingsProvisioningView,
},
meta: {
middleware: ['authenticated', 'rbac', 'custom' /* 'enterprise' */],
middlewareOptions: {
/*
TODO: comment this back in once the custom check using experiment is no longer used
enterprise: {
feature: EnterpriseEditionFeature.Provisioning,
},
*/
rbac: {
scope: 'provisioning:manage',
},
custom: () => {
const posthogStore = usePostHog();
return posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name);
},
},
telemetry: {
pageCategory: 'settings',
getProperties() {
return {
feature: 'provisioning',
};
},
},
},
},
],
},
{

View File

@ -1,165 +0,0 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch } from 'vue';
import { useAccessSettingsCsvExport } from '@/features/settings/provisioning/composables/useAccessSettingsCsvExport';
const visible = defineModel<boolean>();
const emit = defineEmits<{
confirmProvisioning: [value?: string];
cancel: [];
}>();
const locale = useI18n();
const downloadingInstanceRolesCsv = ref(false);
const downloadingProjectRolesCsv = ref(false);
const loadingActivatingJit = ref(false);
const {
hasDownloadedInstanceRoleCsv,
hasDownloadedProjectRoleCsv,
downloadProjectRolesCsv,
downloadInstanceRolesCsv,
accessSettingsCsvExportOnModalClose,
} = useAccessSettingsCsvExport();
watch(visible, () => {
loadingActivatingJit.value = false;
accessSettingsCsvExportOnModalClose();
});
const onDownloadInstanceRolesCsv = async () => {
downloadingInstanceRolesCsv.value = true;
try {
await downloadInstanceRolesCsv();
} finally {
downloadingInstanceRolesCsv.value = false;
}
};
const onDownloadProjectRolesCsv = async () => {
downloadingProjectRolesCsv.value = true;
try {
await downloadProjectRolesCsv();
} finally {
downloadingProjectRolesCsv.value = false;
}
};
const onConfirmActivatingProvisioning = () => {
loadingActivatingJit.value = true;
emit('confirmProvisioning');
};
</script>
<template>
<ElDialog
v-model="visible"
:title="locale.baseText('settings.provisioningConfirmDialog.title')"
width="650"
>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.firstLine')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeRequiredSteps')
}}</N8nText>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-instance-roles-csv-button"
:disabled="downloadingInstanceRolesCsv"
:loading="downloadingInstanceRolesCsv"
:class="$style.button"
@click="onDownloadInstanceRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedInstanceRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-project-roles-csv-button"
:disabled="downloadingProjectRolesCsv"
:loading="downloadingProjectRolesCsv"
:class="$style.button"
@click="onDownloadProjectRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadProjectRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedProjectRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<template #footer>
<N8nButton
type="tertiary"
native-type="button"
data-test-id="provisioning-cancel-button"
@click="emit('cancel')"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.cancel') }}</N8nButton
>
<N8nButton
type="primary"
native-type="button"
:disabled="
loadingActivatingJit || !(hasDownloadedInstanceRoleCsv && hasDownloadedProjectRoleCsv)
"
data-test-id="provisioning-confirm-button"
@click="onConfirmActivatingProvisioning"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.confirm') }}</N8nButton
>
</template>
</ElDialog>
</template>
<style lang="scss" module>
.buttonRow {
display: flex;
align-items: center;
}
.button {
min-width: 340px;
}
.icon {
margin-left: var(--spacing--xs);
}
.list {
padding: 0 var(--spacing--sm);
li {
list-style: disc outside;
}
}
</style>

View File

@ -1,250 +0,0 @@
<script lang="ts" setup>
import { onMounted, ref, computed, reactive } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useToast } from '@/app/composables/useToast';
import { useProvisioningStore } from '../provisioning.store';
import { N8nHeading, N8nText, N8nSpinner, N8nInput, N8nButton } from '@n8n/design-system';
import { type ProvisioningConfig } from '@n8n/rest-api-client';
import EnableJitProvisioningDialog from '../components/EnableJitProvisioningDialog.vue';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const { showError, showMessage } = useToast();
const provisioningStore = useProvisioningStore();
// Check if provisioning feature is enabled
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.provisioning.title'));
loading.value = true;
try {
await provisioningStore.getProvisioningConfig();
loadFormData();
} catch (error) {
showError(error, i18n.baseText('settings.provisioning.loadError'));
} finally {
loading.value = false;
}
});
const loading = ref(false);
const saving = ref(false);
const confirmationDialogVisible = ref(false);
// Form data (reactive object)
const form = reactive({
scopesName: '',
scopesInstanceRoleClaimName: '',
scopesProjectsRolesClaimName: '',
provisioningEnabled: false,
});
const isFormDirty = computed(() => {
const config = provisioningStore.provisioningConfig;
if (!config) return false;
const formKeysThatMatchWithConfig: Array<keyof typeof form & keyof ProvisioningConfig> = [
'scopesName',
'scopesInstanceRoleClaimName',
'scopesProjectsRolesClaimName',
];
const configChanged = formKeysThatMatchWithConfig.some((key) => form[key] !== config[key]);
const provisioningEnabledChanged =
form.provisioningEnabled !==
(config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles);
return configChanged || provisioningEnabledChanged;
});
const loadFormData = () => {
const cfg = provisioningStore.provisioningConfig;
if (!cfg) return;
Object.assign(form, {
scopesName: cfg.scopesName || '',
scopesInstanceRoleClaimName: cfg.scopesInstanceRoleClaimName || '',
scopesProjectsRolesClaimName: cfg.scopesProjectsRolesClaimName || '',
});
form.provisioningEnabled = cfg.scopesProvisionInstanceRole;
};
const saveFormValues = async () => {
saving.value = true;
try {
const { provisioningEnabled, ...dataToSave } = form;
await provisioningStore.saveProvisioningConfig({
...dataToSave,
scopesProvisionInstanceRole: provisioningEnabled,
scopesProvisionProjectRoles: provisioningEnabled,
});
await provisioningStore.getProvisioningConfig();
loadFormData();
// Show success message
showMessage({
title: i18n.baseText('settings.provisioning.saveSuccess'),
message: i18n.baseText('settings.provisioning.saveSuccessMessage'),
type: 'success',
duration: 3000,
});
} catch (error) {
showError(error, i18n.baseText('settings.provisioning.saveError'));
} finally {
saving.value = false;
}
};
const onSave = async () => {
if (form.provisioningEnabled) {
confirmationDialogVisible.value = true;
return;
}
await saveFormValues();
};
const onConfirmProvisioning = async () => {
saving.value = true;
await saveFormValues();
confirmationDialogVisible.value = false;
};
</script>
<template>
<div :class="$style.container">
<div :class="$style.heading">
<N8nHeading size="2xlarge">{{ i18n.baseText('settings.provisioning.title') }}</N8nHeading>
</div>
<N8nText color="text-light">
{{ i18n.baseText('settings.provisioning.description') }}
</N8nText>
<div v-if="loading" :class="$style.loading">
<N8nSpinner size="large" />
</div>
<div v-else>
<div :class="$style.group">
<label for="provisioning-enabled">{{
i18n.baseText('settings.provisioning.toggle')
}}</label>
<small>{{ i18n.baseText('settings.provisioning.toggle.help') }}</small>
<input
id="provisioning-enabled"
v-model="form.provisioningEnabled"
type="checkbox"
:class="$style.checkbox"
/>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesName') }}</label>
<N8nInput
v-model="form.scopesName"
type="text"
size="large"
:placeholder="i18n.baseText('settings.provisioning.scopesName.placeholder')"
/>
<small>{{ i18n.baseText('settings.provisioning.scopesName.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName') }}</label>
<N8nInput
v-model="form.scopesInstanceRoleClaimName"
type="text"
size="large"
:placeholder="
i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName.placeholder')
"
/>
<small>{{ i18n.baseText('settings.provisioning.scopesInstanceRoleClaimName.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName') }}</label>
<N8nInput
v-model="form.scopesProjectsRolesClaimName"
type="text"
size="large"
:placeholder="
i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName.placeholder')
"
/>
<small>{{
i18n.baseText('settings.provisioning.scopesProjectsRolesClaimName.help')
}}</small>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isFormDirty || saving"
size="large"
:loading="saving"
@click="onSave"
>
{{ i18n.baseText('settings.provisioning.save') }}
</N8nButton>
</div>
<EnableJitProvisioningDialog
v-model="confirmationDialogVisible"
@confirm-provisioning="onConfirmProvisioning"
@cancel="confirmationDialogVisible = false"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
padding-bottom: var(--spacing--2xl);
max-width: 600px;
}
.heading {
margin-bottom: var(--spacing--sm);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing--2xl);
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing--2xl) 0 var(--spacing--2xs);
button {
margin: 0 var(--spacing--sm) 0 0;
}
}
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.frequencySelect {
display: block;
width: 240px;
}
.checkbox {
margin-right: var(--spacing--xs);
transform: scale(1.2);
}
</style>

View File

@ -0,0 +1,302 @@
<script lang="ts" setup>
import CopyInput from '@/app/components/CopyInput.vue';
import { MODAL_CONFIRM } from '@/app/constants';
import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { ElSwitch } from 'element-plus';
import { N8nActionBox, 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,
} from '../provisioning/components/UserRoleProvisioningDropdown.vue';
import { useUserRoleProvisioningForm } from '../provisioning/composables/useUserRoleProvisioningForm';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRootStore } from '@n8n/stores/useRootStore';
import { type OidcConfigDto } from '@n8n/api-types';
import ConfirmProvisioningDialog from '../provisioning/components/ConfirmProvisioningDialog.vue';
const i18n = useI18n();
const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const discoveryEndpoint = ref('');
const clientId = ref('');
const clientSecret = ref('');
const showUserRoleProvisioningDialog = ref(false);
const userRoleProvisioning = ref<UserRoleProvisioningSetting>('disabled');
const { isUserRoleProvisioningChanged, saveProvisioningConfig } =
useUserRoleProvisioningForm(userRoleProvisioning);
type PromptType = 'login' | 'none' | 'consent' | 'select_account' | 'create';
const prompt = ref<PromptType>('select_account');
const handlePromptChange = (value: PromptType) => {
prompt.value = value;
};
type PromptDescription = {
label: string;
value: PromptType;
};
const promptDescriptions: PromptDescription[] = [
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.login'), value: 'login' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.none'), value: 'none' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.consent'), value: 'consent' },
{
label: i18n.baseText('settings.sso.settings.oidc.prompt.select_account'),
value: 'select_account',
},
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.create'), value: 'create' },
];
const oidcActivatedLabel = computed(() =>
ssoStore.isOidcLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const authenticationContextClassReference = ref('');
const getOidcConfig = async () => {
const config = await ssoStore.getOidcConfig();
clientId.value = config.clientId;
clientSecret.value = config.clientSecret;
discoveryEndpoint.value = config.discoveryEndpoint;
prompt.value = config.prompt ?? 'select_account';
authenticationContextClassReference.value =
config.authenticationContextClassReference?.join(',') || '';
};
const loadOidcConfig = async () => {
if (!ssoStore.isEnterpriseOidcEnabled) {
return;
}
try {
await getOidcConfig();
} catch (error) {
toast.showError(error, 'error');
}
};
const cannotSaveOidcSettings = computed(() => {
const currentAcrString = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',');
const storedAcrString = ssoStore.oidcConfig?.authenticationContextClassReference?.join(',') || '';
return (
ssoStore.oidcConfig?.clientId === clientId.value &&
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled &&
ssoStore.oidcConfig?.prompt === prompt.value &&
!isUserRoleProvisioningChanged() &&
storedAcrString === authenticationContextClassReference.value &&
currentAcrString === storedAcrString
);
});
async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false) {
if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) {
const confirmAction = await message.confirm(
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'),
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
),
},
);
if (confirmAction !== MODAL_CONFIRM) return;
}
if (isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
showUserRoleProvisioningDialog.value = true;
return;
}
const acrArray = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
try {
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
clientSecret: clientSecret.value,
discoveryEndpoint: discoveryEndpoint.value,
prompt: prompt.value,
loginEnabled: ssoStore.isOidcLoginEnabled,
authenticationContextClassReference: acrArray,
});
if (isUserRoleProvisioningChanged()) {
await saveProvisioningConfig();
showUserRoleProvisioningDialog.value = false;
}
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = SupportedProtocols.OIDC;
clientSecret.value = newConfig.clientSecret;
sendTrackingEvent(newConfig);
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error_oidc'));
return;
} finally {
await getOidcConfig();
}
}
function sendTrackingEvent(config: OidcConfigDto) {
const trackingMetadata = {
instance_id: useRootStore().instanceId,
authentication_method: SupportedProtocols.OIDC,
discovery_endpoint: config.discoveryEndpoint,
is_active: config.loginEnabled,
};
telemetry.track('User updated single sign on settings', trackingMetadata);
}
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(async () => {
await loadOidcConfig();
});
</script>
<template>
<div v-if="ssoStore.isEnterpriseOidcEnabled">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="ssoStore.oidc.callbackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>
<small>Copy the Redirect URL to configure your OIDC provider </small>
</div>
<div :class="$style.group">
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
type="text"
data-test-id="oidc-discovery-endpoint"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@update:model-value="(v: string) => (discoveryEndpoint = v)"
/>
<small>Paste here your discovery endpoint</small>
</div>
<div :class="$style.group">
<label>Client ID</label>
<N8nInput
:model-value="clientId"
type="text"
data-test-id="oidc-client-id"
@update:model-value="(v: string) => (clientId = v)"
/>
<small>The client ID you received when registering your application with your provider</small>
</div>
<div :class="$style.group">
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
type="password"
data-test-id="oidc-client-secret"
@update:model-value="(v: string) => (clientSecret = v)"
/>
<small
>The client Secret you received when registering your application with your provider</small
>
</div>
<div :class="$style.group">
<label>Prompt</label>
<N8nSelect
:model-value="prompt"
data-test-id="oidc-prompt"
@update:model-value="handlePromptChange"
>
<N8nOption
v-for="option in promptDescriptions"
:key="option.value"
:label="option.label"
data-test-id="oidc-prompt-filter-option"
:value="option.value"
/>
</N8nSelect>
<small>The prompt parameter to use when authenticating with the OIDC provider</small>
</div>
<UserRoleProvisioningDropdown v-model="userRoleProvisioning" auth-protocol="oidc" />
<ConfirmProvisioningDialog
v-model="showUserRoleProvisioningDialog"
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="oidc"
@confirm-provisioning="onOidcSettingsSave(true)"
/>
<div :class="$style.group">
<label>Authentication Context Class Reference</label>
<N8nInput
:model-value="authenticationContextClassReference"
type="textarea"
data-test-id="oidc-authentication-context-class-reference"
placeholder="mfa, phrh, pwd"
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
/>
<small
>ACR values to include in the authorization request (acr_values parameter), separated by
commas in order of preference.</small
>
</div>
<div :class="$style.group">
<ElSwitch
v-model="ssoStore.isOidcLoginEnabled"
data-test-id="sso-oidc-toggle"
:class="$style.switch"
:inactive-text="oidcActivatedLabel"
/>
</div>
<div :class="$style.buttons">
<N8nButton
data-test-id="sso-oidc-save"
size="large"
:disabled="cannotSaveOidcSettings"
@click="onOidcSettingsSave(false)"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</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" />

View File

@ -0,0 +1,338 @@
<script lang="ts" setup>
import type { SamlPreferences } from '@n8n/api-types';
import CopyInput from '@/app/components/CopyInput.vue';
import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { captureMessage } from '@sentry/vue';
import { ElSwitch } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nRadioButtons, N8nTooltip } 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, {
type UserRoleProvisioningSetting,
} from '../provisioning/components/UserRoleProvisioningDropdown.vue';
import { useUserRoleProvisioningForm } from '../provisioning/composables/useUserRoleProvisioningForm';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import ConfirmProvisioningDialog from '../provisioning/components/ConfirmProvisioningDialog.vue';
const i18n = useI18n();
const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const redirectUrl = ref();
const IdentityProviderSettingsType = {
URL: 'url',
XML: 'xml',
};
const ipsOptions = ref([
{
label: i18n.baseText('settings.sso.settings.ips.options.url'),
value: IdentityProviderSettingsType.URL,
},
{
label: i18n.baseText('settings.sso.settings.ips.options.xml'),
value: IdentityProviderSettingsType.XML,
},
]);
const ipsType = ref(IdentityProviderSettingsType.URL);
const ssoActivatedLabel = computed(() =>
ssoStore.isSamlLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const metadataUrl = ref();
const metadata = ref();
const ssoSettingsSaved = ref(false);
const entityId = ref();
const showUserRoleProvisioningDialog = ref(false);
const userRoleProvisioning = ref<UserRoleProvisioningSetting>('disabled');
const { isUserRoleProvisioningChanged, saveProvisioningConfig } =
useUserRoleProvisioningForm(userRoleProvisioning);
async function loadSamlConfig() {
if (!ssoStore.isEnterpriseSamlEnabled) {
return;
}
try {
await getSamlConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig();
entityId.value = config?.entityID;
redirectUrl.value = config?.returnUrl;
if (config?.metadataUrl) {
ipsType.value = IdentityProviderSettingsType.URL;
} else if (config?.metadata) {
ipsType.value = IdentityProviderSettingsType.XML;
}
metadata.value = config?.metadata;
metadataUrl.value = config?.metadataUrl;
ssoSettingsSaved.value = !!config?.metadata;
};
const isSaveEnabled = computed(() => {
if (isUserRoleProvisioningChanged()) {
return true;
} else if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && metadata.value !== ssoStore.samlConfig?.metadata;
}
return false;
});
const isTestEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && ssoSettingsSaved.value;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && ssoSettingsSaved.value;
}
return false;
});
const sendTrackingEvent = (config?: SamlPreferences) => {
if (!config) {
captureMessage('Single Sign-On SAML: telemtetry data undefined on submit', { level: 'error' });
return;
}
const trackingMetadata = {
instance_id: useRootStore().instanceId,
authentication_method: SupportedProtocols.SAML,
identity_provider: config.metadataUrl ? 'metadata' : 'xml',
is_active: config.loginEnabled ?? false,
};
telemetry.track('User updated single sign on settings', trackingMetadata);
};
const onSave = async (provisioningChangesConfirmed: boolean = false) => {
try {
validateSamlInput();
if (isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
showUserRoleProvisioningDialog.value = true;
return;
}
const config: Partial<SamlPreferences> =
ipsType.value === IdentityProviderSettingsType.URL
? { metadataUrl: metadataUrl.value }
: { metadata: metadata.value };
const configResponse = await ssoStore.saveSamlConfig(config);
if (isUserRoleProvisioningChanged()) {
await saveProvisioningConfig();
showUserRoleProvisioningDialog.value = false;
}
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = SupportedProtocols.SAML;
// Update store with saved metadata config
ssoStore.samlConfig!.metadata = config.metadata;
ssoStore.samlConfig!.metadataUrl = config.metadataUrl;
if (!ssoStore.isSamlLoginEnabled) {
const answer = await message.confirm(
i18n.baseText('settings.sso.settings.save.activate.message'),
i18n.baseText('settings.sso.settings.save.activate.title'),
{
confirmButtonText: i18n.baseText('settings.sso.settings.save.activate.test'),
cancelButtonText: i18n.baseText('settings.sso.settings.save.activate.cancel'),
},
);
if (answer === 'confirm') {
await onTest();
}
}
await getSamlConfig();
sendTrackingEvent(configResponse);
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error'));
return;
}
};
const onTest = async () => {
try {
const url = await ssoStore.testSamlConfig();
if (typeof window !== 'undefined') {
window.open(url, '_blank');
}
} catch (error) {
toast.showError(error, 'error');
}
};
const validateSamlInput = () => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
// In case the user wants to set the metadata url we want to be sure that
// the provided url is at least a valid http, https url.
try {
const parsedUrl = new URL(metadataUrl.value);
// We allow http and https URLs for now, because we want to avoid a theoretical breaking
// change, this should be restricted to only allow https when switching to V2.
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
// The content of this error is never seen by the user, because the catch clause
// below catches it and translates it to a more general error message.
throw new Error('The provided protocol is not supported');
}
} catch (error) {
throw new Error(i18n.baseText('settings.sso.settings.ips.url.invalid'));
}
}
};
const isToggleSsoDisabled = computed(() => {
/** Allow users to disable SSO even if config request fails */
if (ssoStore.isSamlLoginEnabled) {
return false;
}
return !ssoSettingsSaved.value;
});
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 :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<N8nRadioButtons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-if="ipsType === IdentityProviderSettingsType.URL">
<N8nInput
v-model="metadataUrl"
type="text"
name="metadataUrl"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-if="ipsType === IdentityProviderSettingsType.XML">
<N8nInput
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
</div>
<UserRoleProvisioningDropdown v-model="userRoleProvisioning" auth-protocol="saml" />
<ConfirmProvisioningDialog
v-model="showUserRoleProvisioningDialog"
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="saml"
@confirm-provisioning="onSave(true)"
/>
<div :class="$style.group">
<N8nTooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<ElSwitch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</N8nTooltip>
</div>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isSaveEnabled"
size="large"
data-test-id="sso-save"
@click="onSave(false)"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</N8nButton>
<N8nButton
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</N8nButton>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</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" />

View File

@ -0,0 +1,265 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { N8nButton, N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system';
import { ref, watch, computed } from 'vue';
import { useAccessSettingsCsvExport } from '@/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport';
import type { UserRoleProvisioningSetting } from './UserRoleProvisioningDropdown.vue';
import type { SupportedProtocolType } from '../../sso.store';
const visible = defineModel<boolean>();
const props = defineProps<{
newProvisioningSetting: UserRoleProvisioningSetting;
authProtocol: SupportedProtocolType;
}>();
const emit = defineEmits<{
confirmProvisioning: [];
cancel: [];
}>();
const locale = useI18n();
const downloadingInstanceRolesCsv = ref(false);
const downloadingProjectRolesCsv = ref(false);
const loading = ref(false);
const confirmationChecked = ref(false);
const {
hasDownloadedInstanceRoleCsv,
hasDownloadedProjectRoleCsv,
downloadProjectRolesCsv,
downloadInstanceRolesCsv,
accessSettingsCsvExportOnModalClose,
} = useAccessSettingsCsvExport();
const isDisablingProvisioning = computed(() => props.newProvisioningSetting === 'disabled');
const messagingKey = computed(() => (isDisablingProvisioning.value ? 'disable' : 'enable'));
const shouldShowProjectRolesCsv = computed(
() => props.newProvisioningSetting === 'instance_and_project_roles',
);
watch(visible, () => {
loading.value = false;
confirmationChecked.value = false;
accessSettingsCsvExportOnModalClose();
});
const onDownloadInstanceRolesCsv = async () => {
downloadingInstanceRolesCsv.value = true;
try {
await downloadInstanceRolesCsv();
} finally {
downloadingInstanceRolesCsv.value = false;
}
};
const onDownloadProjectRolesCsv = async () => {
downloadingProjectRolesCsv.value = true;
try {
await downloadProjectRolesCsv();
} finally {
downloadingProjectRolesCsv.value = false;
}
};
const onConfirmProvisioningSetting = () => {
loading.value = true;
emit('confirmProvisioning');
};
</script>
<template>
<ElDialog
v-model="visible"
:title="locale.baseText(`settings.provisioningConfirmDialog.${messagingKey}.title`)"
width="650"
>
<template v-if="!isDisablingProvisioning">
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.firstLine')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.one')
}}</N8nText>
</li>
<li v-if="newProvisioningSetting === 'instance_and_project_roles'">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base"
><a
:href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`"
target="_blank"
>{{ locale.baseText('settings.provisioningConfirmDialog.link.docs') }}</a
></N8nText
>
</div>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeRequiredSteps')
}}</N8nText>
</div>
<div class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-instance-roles-csv-button"
:disabled="downloadingInstanceRolesCsv"
:loading="downloadingInstanceRolesCsv"
:class="$style.button"
@click="onDownloadInstanceRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedInstanceRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
<div v-if="shouldShowProjectRolesCsv" class="mb-s" :class="$style.buttonRow">
<N8nButton
type="secondary"
native-type="button"
data-test-id="provisioning-download-project-roles-csv-button"
:disabled="downloadingProjectRolesCsv"
:loading="downloadingProjectRolesCsv"
:class="$style.button"
@click="onDownloadProjectRolesCsv"
>{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadProjectRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedProjectRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
</template>
<template v-else>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.description')
}}</N8nText>
</div>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.whatWillHappen')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.list.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.list.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.beforeSaving')
}}</N8nText>
</div>
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.checklist.one')
}}</N8nText>
</li>
<li>
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.disable.checklist.two')
}}</N8nText>
</li>
</ul>
<div class="mb-s">
<N8nText color="text-base"
><a
:href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`"
target="_blank"
>{{ locale.baseText('settings.provisioningConfirmDialog.link.docs') }}</a
></N8nText
>
</div>
</template>
<div class="mb-s">
<N8nCheckbox
v-model="confirmationChecked"
:disabled="
!isDisablingProvisioning &&
(!hasDownloadedInstanceRoleCsv ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv))
"
data-test-id="provisioning-confirmation-checkbox"
>
<N8nText color="text-base">{{
locale.baseText(`settings.provisioningConfirmDialog.${messagingKey}.checkbox`)
}}</N8nText>
</N8nCheckbox>
</div>
<template #footer>
<N8nButton
type="tertiary"
native-type="button"
data-test-id="provisioning-cancel-button"
@click="emit('cancel')"
>{{ locale.baseText('settings.provisioningConfirmDialog.button.cancel') }}</N8nButton
>
<N8nButton
type="primary"
native-type="button"
:disabled="
loading ||
!confirmationChecked ||
(!isDisablingProvisioning && !hasDownloadedInstanceRoleCsv) ||
(shouldShowProjectRolesCsv && !hasDownloadedProjectRoleCsv)
"
data-test-id="provisioning-confirm-button"
@click="onConfirmProvisioningSetting"
>{{
locale.baseText(`settings.provisioningConfirmDialog.button.${messagingKey}.confirm`)
}}</N8nButton
>
</template>
</ElDialog>
</template>
<style lang="scss" module>
.buttonRow {
display: flex;
align-items: center;
}
.button {
min-width: 340px;
}
.icon {
margin-left: var(--spacing--xs);
}
.list {
padding: 0 var(--spacing--sm);
li {
list-style: disc outside;
}
}
</style>

View File

@ -0,0 +1,145 @@
<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';
export type UserRoleProvisioningSetting =
| 'disabled'
| 'instance_role'
| 'instance_and_project_roles';
const value = defineModel<UserRoleProvisioningSetting>({ default: 'disabled' });
const { authProtocol } = defineProps<{
authProtocol: SupportedProtocolType;
}>();
const i18n = useI18n();
const posthogStore = usePostHog();
const userRoleProvisioningStore = useUserRoleProvisioningStore();
const isUserRoleProvisioningFeatureEnabled = posthogStore.isFeatureEnabled(
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
);
const handleUserRoleProvisioningChange = (newValue: UserRoleProvisioningSetting) => {
value.value = newValue;
};
const getUserRoleProvisioningValueFromConfig = (
config?: ProvisioningConfig,
): UserRoleProvisioningSetting => {
if (!config) {
return 'disabled';
}
if (config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles) {
return 'instance_and_project_roles';
} else if (config.scopesProvisionInstanceRole) {
return 'instance_role';
} else {
return 'disabled';
}
};
type UserRoleProvisioningDescription = {
label: string;
description: string;
value: UserRoleProvisioningSetting;
};
const userRoleProvisioningDescriptions: UserRoleProvisioningDescription[] = [
{
label: i18n.baseText('settings.sso.settings.userRoleProvisioning.option.disabled.label'),
value: 'disabled',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.disabled.description',
),
},
{
label: i18n.baseText('settings.sso.settings.userRoleProvisioning.option.instanceRole.label'),
value: 'instance_role',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceRole.description',
),
},
{
label: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.label',
),
value: 'instance_and_project_roles',
description: i18n.baseText(
'settings.sso.settings.userRoleProvisioning.option.instanceAndProjectRoles.description',
),
},
];
const loadUserRoleProvisioningConfig = async () => {
const config = await userRoleProvisioningStore.getProvisioningConfig();
value.value = getUserRoleProvisioningValueFromConfig(config);
};
onMounted(async () => {
await loadUserRoleProvisioningConfig();
});
</script>
<template>
<!-- TODO: also check for 'provisioning:manage' permission scope -->
<div v-if="isUserRoleProvisioningFeatureEnabled" :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.label') }}</label>
<N8nSelect
:model-value="value"
data-test-id="oidc-user-role-provisioning"
:class="$style.userRoleProvisioningSelect"
@update:model-value="handleUserRoleProvisioningChange"
>
<N8nOption
v-for="option in userRoleProvisioningDescriptions"
:key="option.value"
:label="option.label"
data-test-id="oidc-user-role-provisioning-option"
:value="option.value"
>
<div class="list-option">
<div class="option-headline">{{ option.label }}</div>
<div class="option-description">{{ option.description }}</div>
</div>
</N8nOption>
</N8nSelect>
<small
>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.help') }}
<a :href="`https://docs.n8n.io/user-management/${authProtocol}/setup/`" target="_blank">{{
i18n.baseText('settings.sso.settings.userRoleProvisioning.help.linkText')
}}</a></small
>
</div>
</template>
<style lang="scss" module>
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.userRoleProvisioningSelect {
display: block;
max-width: 400px;
}
</style>

View File

@ -0,0 +1,76 @@
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';
/**
* Composable for managing user role provisioning form logic in SSO settings.
*/
export function useUserRoleProvisioningForm(
userRoleProvisioning: Ref<UserRoleProvisioningSetting>,
) {
const provisioningStore = useUserRoleProvisioningStore();
const posthogStore = usePostHog();
const getUserRoleProvisioningValueFromConfig = (
config?: ProvisioningConfig,
): UserRoleProvisioningSetting => {
if (!config) {
return 'disabled';
}
if (config.scopesProvisionInstanceRole && config.scopesProvisionProjectRoles) {
return 'instance_and_project_roles';
} else if (config.scopesProvisionInstanceRole) {
return 'instance_role';
} else {
return 'disabled';
}
};
const getProvisioningConfigFromFormValue = (
formValue: UserRoleProvisioningSetting,
): Pick<ProvisioningConfig, 'scopesProvisionInstanceRole' | 'scopesProvisionProjectRoles'> => {
if (formValue === 'instance_role') {
return {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: false,
};
} else if (formValue === 'instance_and_project_roles') {
return {
scopesProvisionInstanceRole: true,
scopesProvisionProjectRoles: true,
};
} else {
return {
scopesProvisionInstanceRole: false,
scopesProvisionProjectRoles: false,
};
}
};
const isUserRoleProvisioningChanged = (): boolean => {
if (!posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name)) {
return false;
}
return (
getUserRoleProvisioningValueFromConfig(provisioningStore.provisioningConfig) !==
userRoleProvisioning.value
);
};
/**
* Saves the current user role provisioning setting to the store.
*/
const saveProvisioningConfig = async (): Promise<void> => {
await provisioningStore.saveProvisioningConfig(
getProvisioningConfigFromFormValue(userRoleProvisioning.value),
);
};
return {
isUserRoleProvisioningChanged,
saveProvisioningConfig,
};
}

View File

@ -1,21 +1,17 @@
import { computed, ref } from 'vue';
import { ref, readonly } from 'vue';
import { defineStore } from 'pinia';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as provisioningApi from '@n8n/rest-api-client/api/provisioning';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
export const useProvisioningStore = defineStore('provisioning', () => {
/**
* Composable to load and save provisioning config
*/
export const useUserRoleProvisioningStore = defineStore('userRoleProvisioning', () => {
const rootStore = useRootStore();
const provisioningConfig = ref<ProvisioningConfig | undefined>();
const isProvisioningEnabled = computed(
() =>
provisioningConfig.value?.scopesProvisionInstanceRole ||
provisioningConfig.value?.scopesProvisionProjectRoles ||
false,
);
const getProvisioningConfig = async () => {
try {
const config = await provisioningApi.getProvisioningConfig(rootStore.restApiContext);
@ -42,8 +38,7 @@ export const useProvisioningStore = defineStore('provisioning', () => {
};
return {
provisioningConfig,
isProvisioningEnabled,
provisioningConfig: readonly(provisioningConfig),
getProvisioningConfig,
saveProvisioningConfig,
};

View File

@ -0,0 +1,48 @@
/**
* Shared styles for SSO forms
*/
.switch {
span {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
}
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing--2xl) 0 var(--spacing--2xs);
button {
margin: 0 var(--spacing--sm) 0 0;
}
}
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.actionBox {
margin: var(--spacing--2xl) 0 0;
}
.footer {
color: var(--color--text);
font-size: var(--font-size--2xs);
}

View File

@ -138,6 +138,15 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
ssoStore.getSamlConfig.mockResolvedValue({
...samlConfig,
metadataUrl: undefined,
metadata: undefined,
});
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadata: undefined });
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -174,6 +183,11 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
// Mock should return config with metadata but WITHOUT metadataUrl (since user filled XML)
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadataUrl: undefined });
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -229,7 +243,8 @@ describe('SettingsSso View', () => {
expect(telemetryTrack).not.toHaveBeenCalled();
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
// getSamlConfig only called once (on mount) since save failed validation
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
});
it('should ensure the url does not support invalid protocols like mailto', async () => {
@ -256,7 +271,8 @@ describe('SettingsSso View', () => {
expect(telemetryTrack).not.toHaveBeenCalled();
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(2);
// getSamlConfig only called once (on mount) since save failed validation
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
});
it('allows user to disable SSO even if config request failed', async () => {
@ -325,16 +341,17 @@ describe('SettingsSso View', () => {
});
it('allows user to save OIDC config', async () => {
ssoStore.saveOidcConfig.mockResolvedValue(oidcConfig);
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isOidcLoginEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.oidcConfig = { ...oidcConfig, discoveryEndpoint: '' };
ssoStore.getOidcConfig.mockResolvedValue({
...oidcConfig,
discoveryEndpoint: '',
});
ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, loginEnabled: true });
const { getByTestId, getByRole } = renderView();
@ -367,6 +384,12 @@ describe('SettingsSso View', () => {
await userEvent.type(clientSecretInput, 'test-client-secret');
expect(saveButton).not.toBeDisabled();
// Pinia mocked stores don't execute real store logic. In production, saveOidcConfig
// updates oidcConfig.value (sso.store.ts:144), but the mock just returns a value.
// We manually update the store to match what the real store would do.
ssoStore.oidcConfig = oidcConfig;
await userEvent.click(saveButton);
expect(ssoStore.saveOidcConfig).toHaveBeenCalledWith(

View File

@ -1,53 +1,16 @@
<script lang="ts" setup>
import CopyInput from '@/app/components/CopyInput.vue';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useMessage } from '@/app/composables/useMessage';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { MODAL_CONFIRM } from '@/app/constants';
import { useSSOStore, SupportedProtocols, type SupportedProtocolType } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { computed, onMounted, ref } from 'vue';
import { ElSwitch } from 'element-plus';
import {
N8nActionBox,
N8nButton,
N8nHeading,
N8nInfoTip,
N8nInput,
N8nOption,
N8nRadioButtons,
N8nSelect,
N8nTooltip,
} from '@n8n/design-system';
const IdentityProviderSettingsType = {
URL: 'url',
XML: 'xml',
};
import { N8nHeading, N8nInfoTip, N8nOption, N8nSelect } from '@n8n/design-system';
import SamlSettingsForm from '../components/SamlSettingsForm.vue';
import OidcSettingsForm from '../components/OidcSettingsForm.vue';
const i18n = useI18n();
const telemetry = useTelemetry();
const rootStore = useRootStore();
const ssoStore = useSSOStore();
const message = useMessage();
const toast = useToast();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
const ssoActivatedLabel = computed(() =>
ssoStore.isSamlLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const oidcActivatedLabel = computed(() =>
ssoStore.isOidcLoginEnabled
? i18n.baseText('settings.sso.activated')
: i18n.baseText('settings.sso.deactivated'),
);
const options = computed(() => {
return [
@ -64,305 +27,16 @@ const options = computed(() => {
];
});
const ssoSettingsSaved = ref(false);
const entityId = ref();
const clientId = ref('');
const clientSecret = ref('');
const discoveryEndpoint = ref('');
type PromptType = 'login' | 'none' | 'consent' | 'select_account' | 'create';
const prompt = ref<PromptType>('select_account');
const handlePromptChange = (value: PromptType) => {
prompt.value = value;
};
type PromptDescription = {
label: string;
value: PromptType;
};
const promptDescriptions: PromptDescription[] = [
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.login'), value: 'login' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.none'), value: 'none' },
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.consent'), value: 'consent' },
{
label: i18n.baseText('settings.sso.settings.oidc.prompt.select_account'),
value: 'select_account',
},
{ label: i18n.baseText('settings.sso.settings.oidc.prompt.create'), value: 'create' },
];
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
const authenticationContextClassReference = ref('');
const ipsOptions = ref([
{
label: i18n.baseText('settings.sso.settings.ips.options.url'),
value: IdentityProviderSettingsType.URL,
},
{
label: i18n.baseText('settings.sso.settings.ips.options.xml'),
value: IdentityProviderSettingsType.XML,
},
]);
const ipsType = ref(IdentityProviderSettingsType.URL);
const metadataUrl = ref();
const metadata = ref();
const redirectUrl = ref();
const isSaveEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && metadataUrl.value !== ssoStore.samlConfig?.metadataUrl;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && metadata.value !== ssoStore.samlConfig?.metadata;
}
return false;
});
const isTestEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && ssoSettingsSaved.value;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && ssoSettingsSaved.value;
}
return false;
});
async function loadSamlConfig() {
if (!ssoStore.isEnterpriseSamlEnabled) {
return;
}
try {
await getSamlConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
const getSamlConfig = async () => {
const config = await ssoStore.getSamlConfig();
entityId.value = config?.entityID;
redirectUrl.value = config?.returnUrl;
if (config?.metadataUrl) {
ipsType.value = IdentityProviderSettingsType.URL;
} else if (config?.metadata) {
ipsType.value = IdentityProviderSettingsType.XML;
}
metadata.value = config?.metadata;
metadataUrl.value = config?.metadataUrl;
ssoSettingsSaved.value = !!config?.metadata;
};
const trackUpdateSettings = () => {
const trackingMetadata: {
instance_id: string;
authentication_method: SupportedProtocolType;
is_active?: boolean;
discovery_endpoint?: string;
identity_provider?: 'metadata' | 'xml';
} = {
instance_id: rootStore.instanceId,
authentication_method: authProtocol.value,
};
if (authProtocol.value === SupportedProtocols.SAML) {
trackingMetadata.identity_provider = ipsType.value === 'url' ? 'metadata' : 'xml';
trackingMetadata.is_active = ssoStore.isSamlLoginEnabled;
} else if (authProtocol.value === SupportedProtocols.OIDC) {
trackingMetadata.discovery_endpoint = discoveryEndpoint.value;
trackingMetadata.is_active = ssoStore.isOidcLoginEnabled;
}
telemetry.track('User updated single sign on settings', trackingMetadata);
};
const onSave = async () => {
try {
validateInput();
const config =
ipsType.value === IdentityProviderSettingsType.URL
? { metadataUrl: metadataUrl.value }
: { metadata: metadata.value };
await ssoStore.saveSamlConfig(config);
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = authProtocol.value;
if (!ssoStore.isSamlLoginEnabled) {
const answer = await message.confirm(
i18n.baseText('settings.sso.settings.save.activate.message'),
i18n.baseText('settings.sso.settings.save.activate.title'),
{
confirmButtonText: i18n.baseText('settings.sso.settings.save.activate.test'),
cancelButtonText: i18n.baseText('settings.sso.settings.save.activate.cancel'),
},
);
if (answer === 'confirm') {
await onTest();
}
}
trackUpdateSettings();
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error'));
return;
} finally {
await getSamlConfig();
}
};
const onTest = async () => {
try {
const url = await ssoStore.testSamlConfig();
if (typeof window !== 'undefined') {
window.open(url, '_blank');
}
} catch (error) {
toast.showError(error, 'error');
}
};
const validateInput = () => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
// In case the user wants to set the metadata url we want to be sure that
// the provided url is at least a valid http, https url.
try {
const parsedUrl = new URL(metadataUrl.value);
// We allow http and https URLs for now, because we want to avoid a theoretical breaking
// change, this should be restricted to only allow https when switching to V2.
if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
// The content of this error is never seen by the user, because the catch clause
// below catches it and translates it to a more general error message.
throw new Error('The provided protocol is not supported');
}
} catch (error) {
throw new Error(i18n.baseText('settings.sso.settings.ips.url.invalid'));
}
}
};
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
const isToggleSsoDisabled = computed(() => {
/** Allow users to disable SSO even if config request fails */
if (ssoStore.isSamlLoginEnabled) {
return false;
}
return !ssoSettingsSaved.value;
});
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.sso.title'));
await Promise.all([loadSamlConfig(), loadOidcConfig()]);
ssoStore.initializeSelectedProtocol();
authProtocol.value = ssoStore.selectedAuthProtocol || SupportedProtocols.SAML;
});
const getOidcConfig = async () => {
const config = await ssoStore.getOidcConfig();
clientId.value = config.clientId;
clientSecret.value = config.clientSecret;
discoveryEndpoint.value = config.discoveryEndpoint;
prompt.value = config.prompt ?? 'select_account';
authenticationContextClassReference.value =
config.authenticationContextClassReference?.join(',') || '';
};
async function loadOidcConfig() {
if (!ssoStore.isEnterpriseOidcEnabled) {
return;
}
try {
await getOidcConfig();
} catch (error) {
toast.showError(error, 'error');
}
}
function onAuthProtocolUpdated(value: SupportedProtocolType) {
authProtocol.value = value;
}
const cannotSaveOidcSettings = computed(() => {
const currentAcrString = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',');
const storedAcrString = ssoStore.oidcConfig?.authenticationContextClassReference?.join(',') || '';
return (
ssoStore.oidcConfig?.clientId === clientId.value &&
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled &&
ssoStore.oidcConfig?.prompt === prompt.value &&
storedAcrString === authenticationContextClassReference.value &&
currentAcrString === storedAcrString
);
onMounted(() => {
documentTitle.set(i18n.baseText('settings.sso.title'));
ssoStore.initializeSelectedProtocol();
authProtocol.value = ssoStore.selectedAuthProtocol || SupportedProtocols.SAML;
});
async function onOidcSettingsSave() {
if (ssoStore.oidcConfig?.loginEnabled && !ssoStore.isOidcLoginEnabled) {
const confirmAction = await message.confirm(
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.message'),
i18n.baseText('settings.oidc.confirmMessage.beforeSaveForm.headline'),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
),
},
);
if (confirmAction !== MODAL_CONFIRM) return;
}
const acrArray = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
try {
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
clientSecret: clientSecret.value,
discoveryEndpoint: discoveryEndpoint.value,
prompt: prompt.value,
loginEnabled: ssoStore.isOidcLoginEnabled,
authenticationContextClassReference: acrArray,
});
// Update store with saved protocol selection
ssoStore.selectedAuthProtocol = authProtocol.value;
clientSecret.value = newConfig.clientSecret;
trackUpdateSettings();
} catch (error) {
toast.showError(error, i18n.baseText('settings.sso.settings.save.error_oidc'));
return;
} finally {
await getOidcConfig();
}
}
</script>
<template>
@ -379,7 +53,7 @@ async function onOidcSettingsSave() {
<div
v-if="ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled"
data-test-id="sso-auth-protocol-select"
:class="$style.group"
:class="shared.group"
>
<label>Select Authentication Protocol</label>
<div>
@ -402,268 +76,18 @@ 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
:value="redirectUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.redirectUrl.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.redirectUrl.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.entityId.label') }}</label>
<CopyInput
:value="entityId"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.sso.settings.entityId.copied')"
/>
<small>{{ i18n.baseText('settings.sso.settings.entityId.help') }}</small>
</div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.ips.label') }}</label>
<div class="mt-2xs mb-s">
<N8nRadioButtons v-model="ipsType" :options="ipsOptions" />
</div>
<div v-if="ipsType === IdentityProviderSettingsType.URL">
<N8nInput
v-model="metadataUrl"
type="text"
name="metadataUrl"
size="large"
:placeholder="i18n.baseText('settings.sso.settings.ips.url.placeholder')"
data-test-id="sso-provider-url"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.url.help') }}</small>
</div>
<div v-if="ipsType === IdentityProviderSettingsType.XML">
<N8nInput
v-model="metadata"
type="textarea"
name="metadata"
:rows="4"
data-test-id="sso-provider-xml"
/>
<small>{{ i18n.baseText('settings.sso.settings.ips.xml.help') }}</small>
</div>
<div :class="$style.group">
<N8nTooltip
v-if="ssoStore.isEnterpriseSamlEnabled"
:disabled="ssoStore.isSamlLoginEnabled || ssoSettingsSaved"
>
<template #content>
<span>
{{ i18n.baseText('settings.sso.activation.tooltip') }}
</span>
</template>
<ElSwitch
v-model="ssoStore.isSamlLoginEnabled"
data-test-id="sso-toggle"
:disabled="isToggleSsoDisabled"
:class="$style.switch"
:inactive-text="ssoActivatedLabel"
/>
</N8nTooltip>
</div>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isSaveEnabled"
size="large"
data-test-id="sso-save"
@click="onSave"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</N8nButton>
<N8nButton
:disabled="!isTestEnabled"
size="large"
type="tertiary"
data-test-id="sso-test"
@click="onTest"
>
{{ i18n.baseText('settings.sso.settings.test') }}
</N8nButton>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</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>
<SamlSettingsForm />
</div>
<div v-if="authProtocol === SupportedProtocols.OIDC">
<div v-if="ssoStore.isEnterpriseOidcEnabled">
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="ssoStore.oidc.callbackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>
<small>Copy the Redirect URL to configure your OIDC provider </small>
</div>
<div :class="$style.group">
<label>Discovery Endpoint</label>
<N8nInput
:model-value="discoveryEndpoint"
type="text"
data-test-id="oidc-discovery-endpoint"
placeholder="https://accounts.google.com/.well-known/openid-configuration"
@update:model-value="(v: string) => (discoveryEndpoint = v)"
/>
<small>Paste here your discovery endpoint</small>
</div>
<div :class="$style.group">
<label>Client ID</label>
<N8nInput
:model-value="clientId"
type="text"
data-test-id="oidc-client-id"
@update:model-value="(v: string) => (clientId = v)"
/>
<small
>The client ID you received when registering your application with your provider</small
>
</div>
<div :class="$style.group">
<label>Client Secret</label>
<N8nInput
:model-value="clientSecret"
type="password"
data-test-id="oidc-client-secret"
@update:model-value="(v: string) => (clientSecret = v)"
/>
<small
>The client Secret you received when registering your application with your
provider</small
>
</div>
<div :class="$style.group">
<label>Prompt</label>
<N8nSelect
:model-value="prompt"
data-test-id="oidc-prompt"
@update:model-value="handlePromptChange"
>
<N8nOption
v-for="option in promptDescriptions"
:key="option.value"
:label="option.label"
data-test-id="oidc-prompt-filter-option"
:value="option.value"
/>
</N8nSelect>
<small>The prompt parameter to use when authenticating with the OIDC provider</small>
</div>
<div :class="$style.group">
<label>Authentication Context Class Reference</label>
<N8nInput
:model-value="authenticationContextClassReference"
type="textarea"
data-test-id="oidc-authentication-context-class-reference"
placeholder="mfa, phrh, pwd"
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
/>
<small
>ACR values to include in the authorization request (acr_values parameter), separated by
commas in order of preference.</small
>
</div>
<div :class="$style.group">
<ElSwitch
v-model="ssoStore.isOidcLoginEnabled"
data-test-id="sso-oidc-toggle"
:class="$style.switch"
:inactive-text="oidcActivatedLabel"
/>
</div>
<div :class="$style.buttons">
<N8nButton
data-test-id="sso-oidc-save"
size="large"
:disabled="cannotSaveOidcSettings"
@click="onOidcSettingsSave"
>
{{ i18n.baseText('settings.sso.settings.save') }}
</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>
<OidcSettingsForm />
</div>
</div>
</template>
<style lang="scss" module="shared" src="../styles/sso-form.module.scss" />
<style lang="scss" module>
.heading {
margin-bottom: var(--spacing--sm);
}
.switch {
span {
font-size: var(--font-size--2xs);
font-weight: var(--font-weight--bold);
color: var(--color--text--tint-1);
}
}
.buttons {
display: flex;
justify-content: flex-start;
padding: var(--spacing--2xl) 0 var(--spacing--2xs);
button {
margin: 0 var(--spacing--sm) 0 0;
}
}
.group {
padding: var(--spacing--xl) 0 0;
> label {
display: inline-block;
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
padding: 0 0 var(--spacing--2xs);
}
small {
display: block;
padding: var(--spacing--2xs) 0 0;
font-size: var(--font-size--2xs);
color: var(--color--text);
}
}
.actionBox {
margin: var(--spacing--2xl) 0 0;
}
.footer {
color: var(--color--text);
font-size: var(--font-size--2xs);
}
</style>