From 34039b370b4d29831f3e8d25a6b89ac3dcc963ff Mon Sep 17 00:00:00 2001 From: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:40:39 +0100 Subject: [PATCH] feat(core): Move settings for SSO user role provisioning from dedicated page to existing form (#21901) --- .../frontend/@n8n/i18n/src/locales/en.json | 25 +- .../src/app/components/SettingsSidebar.vue | 15 +- .../editor-ui/src/app/constants/navigation.ts | 1 - .../frontend/editor-ui/src/app/router.test.ts | 36 +- packages/frontend/editor-ui/src/app/router.ts | 43 +- .../EnableJitProvisioningDialog.vue | 165 ----- .../views/SettingsProvisioningView.vue | 250 -------- .../sso/components/OidcSettingsForm.vue | 302 +++++++++ .../sso/components/SamlSettingsForm.vue | 338 ++++++++++ .../components/ConfirmProvisioningDialog.vue | 265 ++++++++ .../UserRoleProvisioningDropdown.vue | 145 +++++ .../composables/useAccessSettingsCsvExport.ts | 0 .../useUserRoleProvisioningForm.ts | 76 +++ .../userRoleProvisioning.store.ts} | 17 +- .../settings/sso/styles/sso-form.module.scss | 48 ++ .../settings/sso/views/SettingsSso.test.ts | 29 +- .../settings/sso/views/SettingsSso.vue | 600 +----------------- 17 files changed, 1244 insertions(+), 1111 deletions(-) delete mode 100644 packages/frontend/editor-ui/src/features/settings/provisioning/components/EnableJitProvisioningDialog.vue delete mode 100644 packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/components/SamlSettingsForm.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/ConfirmProvisioningDialog.vue create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue rename packages/frontend/editor-ui/src/features/settings/{ => sso}/provisioning/composables/useAccessSettingsCsvExport.ts (100%) create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts rename packages/frontend/editor-ui/src/features/settings/{provisioning/provisioning.store.ts => sso/provisioning/composables/userRoleProvisioning.store.ts} (76%) create mode 100644 packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index e9e7daba1ec..dfc4c51a4f6 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue index c6f833de49f..66eade0ca3e 100644 --- a/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue +++ b/packages/frontend/editor-ui/src/app/components/SettingsSidebar.vue @@ -1,7 +1,6 @@ - - - diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue b/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue deleted file mode 100644 index 1a20e78946e..00000000000 --- a/packages/frontend/editor-ui/src/features/settings/provisioning/views/SettingsProvisioningView.vue +++ /dev/null @@ -1,250 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue new file mode 100644 index 00000000000..f7b3ac124a4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/components/OidcSettingsForm.vue @@ -0,0 +1,302 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue new file mode 100644 index 00000000000..d8e364d1a87 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/components/UserRoleProvisioningDropdown.vue @@ -0,0 +1,145 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport.ts similarity index 100% rename from packages/frontend/editor-ui/src/features/settings/provisioning/composables/useAccessSettingsCsvExport.ts rename to packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useAccessSettingsCsvExport.ts diff --git a/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts new file mode 100644 index 00000000000..610c539a7ae --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/useUserRoleProvisioningForm.ts @@ -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, +) { + 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 => { + 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 => { + await provisioningStore.saveProvisioningConfig( + getProvisioningConfigFromFormValue(userRoleProvisioning.value), + ); + }; + + return { + isUserRoleProvisioningChanged, + saveProvisioningConfig, + }; +} diff --git a/packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts similarity index 76% rename from packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts rename to packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts index f0461df5dd5..fd1aeb734ff 100644 --- a/packages/frontend/editor-ui/src/features/settings/provisioning/provisioning.store.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/provisioning/composables/userRoleProvisioning.store.ts @@ -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(); - 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, }; diff --git a/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss new file mode 100644 index 00000000000..f90a1d7c2d3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/settings/sso/styles/sso-form.module.scss @@ -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); +} diff --git a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts index 9c3de9d3686..d903e8fc1f5 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts +++ b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.test.ts @@ -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( diff --git a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue index 82da078fc79..f3bf8b5c863 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue @@ -1,53 +1,16 @@ +