feat(core): Shorten copy text on confirm provisioning dialog (#22086)

This commit is contained in:
Konstantin Tieber 2025-11-21 15:52:02 +01:00 committed by GitHub
parent 68a81c2ed6
commit 4ddd7089b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 305 additions and 269 deletions

View File

@ -2542,27 +2542,21 @@
"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.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.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.title": "Manage user role provisioning in n8n only",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partOne": "Your SSO provider will control all user roles in n8n.",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partOne.withProjectRoles": "Your SSO provider will control all user and project roles in n8n.",
"settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partTwo": "Roles not assigned by your SSO provider will default to global:member.",
"settings.provisioningConfirmDialog.breakingChangeDescription.secondLine": "<b>Before enabling:</b> Download and review your current access settings below to ensure your SSO provider is configured correctly.",
"settings.provisioningConfirmDialog.disable.description": "You're switching instance role management from SSO back to n8n.",
"settings.provisioningConfirmDialog.enable.checkbox": "I have downloaded and reviewed the CSV export. My SSO provider is correctly configured to control user roles 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.disable.confirm": "Save",
"settings.provisioningConfirmDialog.button.cancel": "Cancel",
"settings.provisioningConfirmDialog.button.generateCsvExport": "Generate access settings CSV export",
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Download existing project access settings csv",
"settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv": "Download existing instance role settings csv",
"settings.provisioningConfirmDialog.button.downloadProjectRolesCsv": "Existing project access settings csv",
"settings.provisioningConfirmDialog.button.downloadInstanceRolesCsv": "Existing instance role settings csv",
"settings.provisioningInstanceRolesHandledBySsoProvider.description": "User management and instance roles are controlled by your SSO provider. Contact your n8n instance owner or admin to make changes.",
"settings.provisioningProjectRolesHandledBySsoProvider.description": "User management and project roles are controlled by your SSO provider. Contact your n8n instance owner or admin to make changes.",
"settings.externalSecrets.title": "External Secrets",
@ -3470,9 +3464,7 @@
"settings.sso.subtitle": "SAML 2.0 Configuration",
"settings.sso.info": "Activate SAML or OIDC to enable passwordless login via your existing user management tool and enhance security through unified authentication.",
"settings.sso.info.link": "Learn how to configure SAML or OIDC.",
"settings.sso.activation.tooltip": "You need to save the settings first before activating SAML",
"settings.sso.activated": "Activated",
"settings.sso.deactivated": "Deactivated",
"settings.sso.activated": "Enable Single Sign On",
"settings.sso.settings.redirectUrl.label": "Redirect URL",
"settings.sso.settings.redirectUrl.copied": "Redirect URL copied to clipboard",
"settings.sso.settings.redirectUrl.help": "Copy the Redirect URL to configure your SAML provider",
@ -3494,26 +3486,25 @@
"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.disabled.label": "Managed in n8n",
"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",
"settings.sso.settings.save.activate.message": "SAML SSO configuration saved successfully. Test your SAML SSO settings first, then activate to enable single sign-on for your organization.",
"settings.sso.settings.save.testConnection.title": "Test and activate SAML SSO",
"settings.sso.settings.save.testConnection.message": "You are about to activate SSO via SAML. Test your SAML SSO settings first before proceeding.",
"settings.sso.settings.save.testConnection.test": "Test settings",
"settings.sso.settings.save.confirmTestConnection.title": "Confirm successful connection test",
"settings.sso.settings.save.confirmTestConnection.message": "Was the connection test successful? Confirm to activate SSO via SAML.",
"settings.sso.settings.save.confirmTestConnection.confirm": "Confirm",
"settings.sso.settings.save.activate.cancel": "Cancel",
"settings.sso.settings.save.activate.test": "Test settings",
"settings.sso.settings.save.error": "Error saving SAML SSO configuration",
"settings.sso.settings.save.error_oidc": "Error saving OIDC SSO configuration",
"settings.sso.settings.footer.hint": "Don't forget to activate SAML SSO once you've saved the settings.",
"settings.sso.actionBox.title": "Available on the Enterprise plan",
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans",
"settings.oidc.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable OIDC login?",
"settings.oidc.confirmMessage.beforeSaveForm.message": "If you do so, all OIDC users will be converted to email users.",
"settings.sso.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable {protocol} login?",
"settings.sso.confirmMessage.beforeSaveForm.message": "If you do so, all {protocol} users will be converted to email users.",
"settings.mfa.secret": "Secret {secret}",
"settings.mfa": "MFA",
"settings.mfa.title": "Multi-factor Authentication",

View File

@ -4,7 +4,7 @@ import { MODAL_CONFIRM } from '@/app/constants';
import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { ElSwitch } from 'element-plus';
import { ElCheckbox } 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';
@ -26,6 +26,8 @@ const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const savingForm = ref<boolean>(false);
const discoveryEndpoint = ref('');
const clientId = ref('');
const clientSecret = ref('');
@ -60,12 +62,6 @@ const promptDescriptions: PromptDescription[] = [
{ 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 () => {
@ -114,8 +110,12 @@ const cannotSaveOidcSettings = computed(() => {
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'),
i18n.baseText('settings.sso.confirmMessage.beforeSaveForm.message', {
interpolate: { protocol: 'OIDC' },
}),
i18n.baseText('settings.sso.confirmMessage.beforeSaveForm.headline', {
interpolate: { protocol: 'OIDC' },
}),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
@ -139,6 +139,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false)
.filter(Boolean);
try {
savingForm.value = true;
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
clientSecret: clientSecret.value,
@ -163,6 +164,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false)
toast.showError(error, i18n.baseText('settings.sso.settings.save.error_oidc'));
return;
} finally {
savingForm.value = false;
await getOidcConfig();
}
}
@ -252,6 +254,7 @@ onMounted(async () => {
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="oidc"
@confirm-provisioning="onOidcSettingsSave(true)"
@cancel="showUserRoleProvisioningDialog = false"
/>
<div :class="$style.group">
<label>Authentication Context Class Reference</label>
@ -267,20 +270,18 @@ onMounted(async () => {
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 :class="[$style.group, $style.checkboxGroup]">
<ElCheckbox v-model="ssoStore.isOidcLoginEnabled" data-test-id="sso-oidc-toggle">{{
i18n.baseText('settings.sso.activated')
}}</ElCheckbox>
</div>
<div :class="$style.buttons">
<N8nButton
data-test-id="sso-oidc-save"
size="large"
:disabled="cannotSaveOidcSettings"
:loading="savingForm"
:disabled="savingForm || cannotSaveOidcSettings"
@click="onOidcSettingsSave(false)"
>
{{ i18n.baseText('settings.sso.settings.save') }}

View File

@ -5,8 +5,8 @@ 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 { ElCheckbox } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nRadioButtons } from '@n8n/design-system';
import { useToast } from '@/app/composables/useToast';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useMessage } from '@/app/composables/useMessage';
@ -18,6 +18,7 @@ import { useUserRoleProvisioningForm } from '../provisioning/composables/useUser
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTelemetry } from '@/app/composables/useTelemetry';
import ConfirmProvisioningDialog from '../provisioning/components/ConfirmProvisioningDialog.vue';
import { MODAL_CONFIRM } from '@/app/constants/modals';
const i18n = useI18n();
const ssoStore = useSSOStore();
@ -26,7 +27,10 @@ const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const savingForm = ref<boolean>(false);
const redirectUrl = ref();
const samlLoginEnabled = ref<boolean>(false);
const IdentityProviderSettingsType = {
URL: 'url',
@ -45,17 +49,9 @@ const ipsOptions = ref([
]);
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);
@ -90,25 +86,32 @@ const getSamlConfig = async () => {
metadata.value = config?.metadata;
metadataUrl.value = config?.metadataUrl;
ssoSettingsSaved.value = !!config?.metadata;
samlLoginEnabled.value = config.loginEnabled ?? false;
};
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;
if (savingForm.value) {
return false;
}
return false;
const isIdentityProviderChanged = () => {
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 isSamlLoginEnabledChanged = ssoStore.isSamlLoginEnabled !== samlLoginEnabled.value;
return (
isUserRoleProvisioningChanged() || isIdentityProviderChanged() || isSamlLoginEnabledChanged
);
});
const isTestEnabled = computed(() => {
if (ipsType.value === IdentityProviderSettingsType.URL) {
return !!metadataUrl.value && ssoSettingsSaved.value;
return !!metadataUrl.value;
} else if (ipsType.value === IdentityProviderSettingsType.XML) {
return !!metadata.value && ssoSettingsSaved.value;
return !!metadata.value;
}
return false;
});
@ -127,20 +130,94 @@ const sendTrackingEvent = (config?: SamlPreferences) => {
telemetry.track('User updated single sign on settings', trackingMetadata);
};
const promptConfirmDisablingSamlLogin = async () => {
const confirmAction = await message.confirm(
i18n.baseText('settings.sso.confirmMessage.beforeSaveForm.message', {
interpolate: { protocol: 'SAML' },
}),
i18n.baseText('settings.sso.confirmMessage.beforeSaveForm.headline', {
interpolate: { protocol: 'SAML' },
}),
{
cancelButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.cancelButtonText',
),
confirmButtonText: i18n.baseText(
'settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText',
),
},
);
return confirmAction;
};
const prompTestSamlConnectionBeforeActivating = async () => {
const promptOpeningTestConnectionPage = await message.confirm(
i18n.baseText('settings.sso.settings.save.testConnection.message'),
i18n.baseText('settings.sso.settings.save.testConnection.title'),
{
confirmButtonText: i18n.baseText('settings.sso.settings.save.testConnection.test'),
cancelButtonText: i18n.baseText('settings.sso.settings.save.activate.cancel'),
},
);
if (promptOpeningTestConnectionPage === MODAL_CONFIRM) {
await onTest();
const promptConfirmingSuccessfulTest = await message.confirm(
i18n.baseText('settings.sso.settings.save.confirmTestConnection.message'),
i18n.baseText('settings.sso.settings.save.confirmTestConnection.title'),
{
confirmButtonText: i18n.baseText(
'settings.sso.settings.save.confirmTestConnection.confirm',
),
cancelButtonText: i18n.baseText('settings.sso.settings.save.activate.cancel'),
},
);
return promptConfirmingSuccessfulTest;
}
return promptOpeningTestConnectionPage;
};
const onSave = async (provisioningChangesConfirmed: boolean = false) => {
try {
savingForm.value = true;
validateSamlInput();
if (isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
const isDisablingSamlLogin = ssoStore.isSamlLoginEnabled && !samlLoginEnabled.value;
if (isDisablingSamlLogin) {
const confirmDisablingSaml = await promptConfirmDisablingSamlLogin();
if (confirmDisablingSaml !== MODAL_CONFIRM) {
return;
}
}
if (!isDisablingSamlLogin && isUserRoleProvisioningChanged() && !provisioningChangesConfirmed) {
showUserRoleProvisioningDialog.value = true;
return;
}
const config: Partial<SamlPreferences> =
const metaDataConfig: Partial<SamlPreferences> =
ipsType.value === IdentityProviderSettingsType.URL
? { metadataUrl: metadataUrl.value }
: { metadata: metadata.value };
const configResponse = await ssoStore.saveSamlConfig(config);
const isActivatingSamlLogin = !ssoStore.isSamlLoginEnabled && samlLoginEnabled.value;
if (isActivatingSamlLogin) {
// metadata settings need to be saved for test to work
await ssoStore.saveSamlConfig(metaDataConfig);
const confirmTest = await prompTestSamlConnectionBeforeActivating();
if (confirmTest !== MODAL_CONFIRM) {
return;
}
}
const configResponse = await ssoStore.saveSamlConfig({
...metaDataConfig,
loginEnabled: samlLoginEnabled.value,
});
if (isUserRoleProvisioningChanged()) {
await saveProvisioningConfig();
@ -149,30 +226,14 @@ const onSave = async (provisioningChangesConfirmed: boolean = 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;
} finally {
savingForm.value = false;
}
};
@ -207,15 +268,6 @@ const validateSamlInput = () => {
}
};
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');
};
@ -276,30 +328,18 @@ onMounted(async () => {
:new-provisioning-setting="userRoleProvisioning"
auth-protocol="saml"
@confirm-provisioning="onSave(true)"
@cancel="showUserRoleProvisioningDialog = false"
/>
<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 :class="[$style.group, $style.checkboxGroup]">
<ElCheckbox v-model="samlLoginEnabled" data-test-id="sso-toggle">{{
i18n.baseText('settings.sso.activated')
}}</ElCheckbox>
</div>
</div>
<div :class="$style.buttons">
<N8nButton
:disabled="!isSaveEnabled"
:loading="savingForm"
size="large"
data-test-id="sso-save"
@click="onSave(false)"
@ -316,10 +356,6 @@ onMounted(async () => {
{{ i18n.baseText('settings.sso.settings.test') }}
</N8nButton>
</div>
<footer :class="$style.footer">
{{ i18n.baseText('settings.sso.settings.footer.hint') }}
</footer>
</div>
<N8nActionBox
v-else

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import { ElDialog } from 'element-plus';
import { N8nButton, N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system';
import { N8nButton, N8nCard, 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';
@ -77,22 +77,23 @@ const onConfirmProvisioningSetting = () => {
>
<template v-if="!isDisablingProvisioning">
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeDescription.firstLine')
}}</N8nText>
<N8nText color="text-base"
>{{
locale.baseText(
newProvisioningSetting === 'instance_and_project_roles'
? 'settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partOne.withProjectRoles'
: 'settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partOne',
)
}}
</N8nText>
<N8nText :class="$style.descriptionTextPartTwo" color="text-base">
{{
locale.baseText(
'settings.provisioningConfirmDialog.breakingChangeDescription.firstSentence.partTwo',
)
}}</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
@ -103,50 +104,51 @@ const onConfirmProvisioningSetting = () => {
>
</div>
<div class="mb-s">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.breakingChangeRequiredSteps')
}}</N8nText>
<N8nText
color="text-base"
v-n8n-html="
locale.baseText(
'settings.provisioningConfirmDialog.breakingChangeDescription.secondLine',
)
"
></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"
>{{
<ul :class="$style.list" class="mb-s">
<li>
<N8nText color="text-base">{{
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"
>{{
}}</N8nText>
<N8nButton
v-if="!hasDownloadedInstanceRoleCsv"
type="highlight"
native-type="button"
:icon="'file-download' as any"
data-test-id="provisioning-download-instance-roles-csv-button"
:disabled="downloadingInstanceRolesCsv"
:loading="downloadingInstanceRolesCsv"
:class="$style.button"
@click="onDownloadInstanceRolesCsv"
></N8nButton>
<N8nIcon v-else icon="check" color="success" :class="$style.icon"></N8nIcon>
</li>
<li v-if="shouldShowProjectRolesCsv">
<N8nText color="text-base">{{
locale.baseText('settings.provisioningConfirmDialog.button.downloadProjectRolesCsv')
}}</N8nButton
>
<N8nIcon
v-if="hasDownloadedProjectRoleCsv"
icon="check"
color="success"
:class="$style.icon"
/>
</div>
}}</N8nText>
<N8nButton
v-if="!hasDownloadedProjectRoleCsv"
type="highlight"
native-type="button"
:icon="'file-download' as any"
data-test-id="provisioning-download-project-roles-csv-button"
:disabled="downloadingProjectRolesCsv"
:loading="downloadingProjectRolesCsv"
:class="$style.button"
@click="onDownloadProjectRolesCsv"
></N8nButton>
<N8nIcon v-else icon="check" color="success" :class="$style.icon"></N8nIcon>
</li>
</ul>
</template>
<template v-else>
<div class="mb-s">
@ -154,40 +156,6 @@ const onConfirmProvisioningSetting = () => {
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
@ -199,19 +167,21 @@ const onConfirmProvisioningSetting = () => {
</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>
<N8nCard :class="$style.card">
<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>
</N8nCard>
</div>
<template #footer>
@ -242,24 +212,35 @@ const onConfirmProvisioningSetting = () => {
</template>
<style lang="scss" module>
.buttonRow {
display: flex;
align-items: center;
}
.button {
min-width: 340px;
}
.icon {
margin-left: var(--spacing--xs);
}
.card {
background-color: var(--color--background--light-1);
}
.descriptionTextPartTwo {
margin-left: 4px;
}
.icon {
height: 32px; // to match height of download button
margin: 0 var(--spacing--lg);
}
.list {
padding: 0 var(--spacing--sm);
padding: 0 var(--spacing--2xs);
li {
list-style: disc outside;
display: flex;
align-items: center;
&::before {
content: '•';
margin-right: var(--spacing--3xs);
margin-bottom: 2px;
}
}
}
</style>

View File

@ -49,7 +49,6 @@ const getUserRoleProvisioningValueFromConfig = (
type UserRoleProvisioningDescription = {
label: string;
description: string;
value: UserRoleProvisioningSetting;
};
@ -57,25 +56,16 @@ 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',
),
},
];
@ -104,12 +94,7 @@ onMounted(async () => {
: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') }}

View File

@ -88,7 +88,6 @@ export const useSSOStore = defineStore('sso', () => {
get: () => saml.value.loginEnabled,
set: (value: boolean) => {
saml.value.loginEnabled = value;
void toggleLoginEnabled(value);
},
});
@ -98,14 +97,13 @@ export const useSSOStore = defineStore('sso', () => {
() => authenticationMethod.value === UserManagementAuthenticationMethod.Saml,
);
const toggleLoginEnabled = async (enabled: boolean) =>
await ssoApi.toggleSamlConfig(rootStore.restApiContext, { loginEnabled: enabled });
const getSamlMetadata = async () => await ssoApi.getSamlMetadata(rootStore.restApiContext);
const getSamlConfig = async () => {
const config = await ssoApi.getSamlConfig(rootStore.restApiContext);
samlConfig.value = config;
saml.value.loginEnabled = config.loginEnabled;
saml.value.loginLabel = config.loginLabel;
return config;
};

View File

@ -38,6 +38,11 @@
}
}
.checkboxGroup label > *:first-child {
// center checkbox next to label
vertical-align: text-top;
}
.actionBox {
margin: var(--spacing--2xl) 0 0;
}

View File

@ -121,31 +121,43 @@ describe('SettingsSso View', () => {
const { getByTestId } = renderView();
const toggle = getByTestId('sso-toggle');
const checkbox = toggle.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(toggle.textContent).toContain('Deactivated');
expect(checkbox).not.toBeChecked();
await userEvent.click(toggle);
expect(toggle.textContent).toContain('Activated');
expect(checkbox).toBeChecked();
await userEvent.click(toggle);
expect(toggle.textContent).toContain('Deactivated');
expect(checkbox).not.toBeChecked();
});
it("allows user to fill Identity Provider's URL", async () => {
confirmMessage.mockResolvedValueOnce('confirm');
// Mock two confirm dialogs: 1) test connection prompt, 2) confirm successful test
confirmMessage.mockResolvedValueOnce('confirm').mockResolvedValueOnce('confirm');
const windowOpenSpy = vi.spyOn(window, 'open');
ssoStore.isEnterpriseSamlEnabled = true;
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
ssoStore.samlConfig = {
...samlConfig,
metadataUrl: undefined,
metadata: undefined,
loginEnabled: false,
};
ssoStore.getSamlConfig.mockResolvedValue({
...samlConfig,
metadataUrl: undefined,
metadata: undefined,
loginEnabled: false,
});
ssoStore.saveSamlConfig.mockResolvedValue({
...samlConfig,
metadata: undefined,
loginEnabled: true,
});
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadata: undefined });
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -158,11 +170,18 @@ describe('SettingsSso View', () => {
expect(urlInput).toBeVisible();
await userEvent.type(urlInput, samlConfig.metadataUrl as string);
// Enable SSO toggle
const toggle = getByTestId('sso-toggle');
await userEvent.click(toggle);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
expect(ssoStore.saveSamlConfig).toHaveBeenCalledWith(
expect.objectContaining({ metadataUrl: samlConfig.metadataUrl }),
expect.objectContaining({
metadataUrl: samlConfig.metadataUrl,
loginEnabled: true,
}),
);
expect(ssoStore.testSamlConfig).toHaveBeenCalled();
@ -177,7 +196,8 @@ describe('SettingsSso View', () => {
});
it("allows user to fill Identity Provider's XML", async () => {
confirmMessage.mockResolvedValueOnce('confirm');
// Mock two confirm dialogs: 1) test connection prompt, 2) confirm successful test
confirmMessage.mockResolvedValueOnce('confirm').mockResolvedValueOnce('confirm');
const windowOpenSpy = vi.spyOn(window, 'open');
@ -185,8 +205,18 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.samlConfig = { ...samlConfig, metadataUrl: undefined, metadata: undefined };
ssoStore.getSamlConfig.mockResolvedValue({
...samlConfig,
metadataUrl: undefined,
metadata: undefined,
loginEnabled: false,
});
// Mock should return config with metadata but WITHOUT metadataUrl (since user filled XML)
ssoStore.saveSamlConfig.mockResolvedValue({ ...samlConfig, metadataUrl: undefined });
ssoStore.saveSamlConfig.mockResolvedValue({
...samlConfig,
metadataUrl: undefined,
loginEnabled: true,
});
ssoStore.testSamlConfig.mockResolvedValue('https://test-url.com');
const { getByTestId } = renderView();
@ -201,11 +231,18 @@ describe('SettingsSso View', () => {
expect(xmlInput).toBeVisible();
await userEvent.type(xmlInput, samlConfig.metadata!);
// Enable SSO toggle
const toggle = getByTestId('sso-toggle');
await userEvent.click(toggle);
expect(saveButton).not.toBeDisabled();
await userEvent.click(saveButton);
expect(ssoStore.saveSamlConfig).toHaveBeenCalledWith(
expect.objectContaining({ metadata: samlConfig.metadata }),
expect.objectContaining({
metadata: samlConfig.metadata,
loginEnabled: true,
}),
);
expect(ssoStore.testSamlConfig).toHaveBeenCalled();
@ -281,19 +318,21 @@ describe('SettingsSso View', () => {
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isOidcLoginEnabled = false;
const error = new Error('Request failed with status code 404');
ssoStore.getSamlConfig.mockRejectedValue(error);
ssoStore.getSamlConfig.mockResolvedValue({
...samlConfig,
loginEnabled: true,
});
const { getByTestId } = renderView();
expect(ssoStore.getSamlConfig).toHaveBeenCalledTimes(1);
await waitFor(async () => {
expect(showError).toHaveBeenCalledWith(error, 'error');
const toggle = getByTestId('sso-toggle');
expect(toggle.textContent).toContain('Activated');
const checkbox = toggle.querySelector('input[type="checkbox"]') as HTMLInputElement;
expect(checkbox).toBeChecked();
await userEvent.click(toggle);
expect(toggle.textContent).toContain('Deactivated');
expect(checkbox).not.toBeChecked();
});
});
@ -312,7 +351,7 @@ describe('SettingsSso View', () => {
expect(container.querySelector('textarea[name="metadata"]')).toHaveValue(samlConfig.metadata);
expect(getByRole('switch')).toBeEnabled();
expect(getByRole('checkbox')).toBeEnabled();
expect(getByTestId('sso-test')).toBeEnabled();
});
});