mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 02:07:06 +02:00
feat(core): Move settings for SSO user role provisioning from dedicated page to existing form (#21901)
This commit is contained in:
parent
8720a0e5f3
commit
34039b370b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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" />
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user