refactor(editor): Extract SAML, OIDC, and LDAP from Settings Store into SSO Store (no-changelog) (#16276)

This commit is contained in:
Alex Grozav
2025-06-16 13:54:31 +02:00
committed by GitHub
parent e93fd1a689
commit c22ca2cb4a
17 changed files with 330 additions and 247 deletions

View File

@@ -2,19 +2,21 @@
import { useSSOStore } from '@/stores/sso.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useSettingsStore } from '@/stores/settings.store';
import { useRoute } from 'vue-router';
const i18n = useI18n();
const ssoStore = useSSOStore();
const toast = useToast();
const settingsStore = useSettingsStore();
const route = useRoute();
const onSSOLogin = async () => {
try {
const redirectUrl = ssoStore.isDefaultAuthenticationSaml
? await ssoStore.getSSORedirectUrl()
: settingsStore.settings.sso.oidc.loginUrl;
window.location.href = redirectUrl;
? await ssoStore.getSSORedirectUrl(
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
)
: ssoStore.oidc.loginUrl;
window.location.href = redirectUrl ?? '';
} catch (error) {
toast.showError(error, 'Error', error.message);
}

View File

@@ -3,12 +3,18 @@ import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { state, initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useSettingsStore } from '@/stores/settings.store';
import { useVersionsStore } from '@/stores/versions.store';
import { AxiosError } from 'axios';
import merge from 'lodash/merge';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { useSSOStore } from '@/stores/sso.store';
import { UserManagementAuthenticationMethod } from '@/Interface';
import { EnterpriseEditionFeature } from '@/constants';
const showMessage = vi.fn();
@@ -31,9 +37,17 @@ describe('Init', () => {
let usersStore: ReturnType<typeof useUsersStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
let versionsStore: ReturnType<typeof useVersionsStore>;
let ssoStore: ReturnType<typeof useSSOStore>;
beforeEach(() => {
setActivePinia(createTestingPinia());
setActivePinia(
createTestingPinia({
initialState: {
[STORES.SETTINGS]: merge({}, SETTINGS_STORE_DEFAULT_STATE),
},
}),
);
settingsStore = useSettingsStore();
cloudPlanStore = useCloudPlanStore();
sourceControlStore = useSourceControlStore();
@@ -41,9 +55,14 @@ describe('Init', () => {
usersStore = useUsersStore();
versionsStore = useVersionsStore();
versionsStore = useVersionsStore();
ssoStore = useSSOStore();
});
describe('initializeCore()', () => {
beforeEach(() => {
state.initialized = false;
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -63,6 +82,28 @@ describe('Init', () => {
expect(settingsStoreSpy).toHaveBeenCalledTimes(1);
});
it('should initialize ssoStore with settings SSO configuration', async () => {
const saml = { loginEnabled: true, loginLabel: '' };
const ldap = { loginEnabled: false, loginLabel: '' };
const oidc = { loginEnabled: false, loginUrl: '', callbackUrl: '' };
settingsStore.userManagement.authenticationMethod = UserManagementAuthenticationMethod.Saml;
settingsStore.settings.sso = { saml, ldap, oidc };
settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml] = true;
await initializeCore();
expect(ssoStore.initialize).toHaveBeenCalledWith({
authenticationMethod: UserManagementAuthenticationMethod.Saml,
config: { saml, ldap, oidc },
features: {
saml: true,
ldap: false,
oidc: false,
},
});
});
});
describe('initializeAuthenticatedFeatures()', () => {

View File

@@ -13,8 +13,13 @@ import { useInsightsStore } from '@/features/insights/insights.store';
import { useToast } from '@/composables/useToast';
import { useI18n } from '@n8n/i18n';
import SourceControlInitializationErrorMessage from '@/components/SourceControlInitializationErrorMessage.vue';
import { useSSOStore } from '@/stores/sso.store';
import { EnterpriseEditionFeature } from '@/constants';
import type { UserManagementAuthenticationMethod } from '@/Interface';
let coreInitialized = false;
export const state = {
initialized: false,
};
let authenticatedFeaturesInitialized = false;
/**
@@ -22,16 +27,28 @@ let authenticatedFeaturesInitialized = false;
* This is called once, when the first route is loaded.
*/
export async function initializeCore() {
if (coreInitialized) {
if (state.initialized) {
return;
}
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const versionsStore = useVersionsStore();
const ssoStore = useSSOStore();
await settingsStore.initialize();
ssoStore.initialize({
authenticationMethod: settingsStore.userManagement
.authenticationMethod as UserManagementAuthenticationMethod,
config: settingsStore.settings.sso,
features: {
saml: settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
ldap: settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Ldap],
oidc: settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc],
},
});
void useExternalHooks().run('app.mount');
if (!settingsStore.isPreviewMode) {
@@ -40,7 +57,7 @@ export async function initializeCore() {
void versionsStore.checkForNewVersions();
}
coreInitialized = true;
state.initialized = true;
}
/**

View File

@@ -3,11 +3,9 @@ import Bowser from 'bowser';
import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types';
import * as eventsApi from '@n8n/rest-api-client/api/events';
import * as ldapApi from '@n8n/rest-api-client/api/ldap';
import * as settingsApi from '@n8n/rest-api-client/api/settings';
import * as promptsApi from '@n8n/rest-api-client/api/prompts';
import { testHealthEndpoint } from '@/api/templates';
import type { LdapConfig } from '@n8n/rest-api-client/api/ldap';
import {
INSECURE_CONNECTION_WARNING,
LOCAL_STORAGE_EXPERIMENTAL_MIN_ZOOM_NODE_SETTINGS_IN_CANVAS,
@@ -44,9 +42,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
enabled: false,
},
});
const ldap = ref({ loginLabel: '', loginEnabled: false });
const saml = ref({ loginLabel: '', loginEnabled: false });
const oidc = ref({ loginEnabled: false, loginUrl: '', callbackUrl: '' });
const mfa = ref({ enabled: false });
const folders = ref({ enabled: false });
@@ -90,16 +85,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const publicApiPath = computed(() => api.value.path);
const isLdapLoginEnabled = computed(() => ldap.value.loginEnabled);
const ldapLoginLabel = computed(() => ldap.value.loginLabel);
const isSamlLoginEnabled = computed(() => saml.value.loginEnabled);
const isOidcLoginEnabled = computed(() => oidc.value.loginEnabled);
const oidcCallBackUrl = computed(() => oidc.value.callbackUrl);
const isAiAssistantEnabled = computed(() => settings.value.aiAssistant?.enabled);
const isAskAiEnabled = computed(() => settings.value.askAi?.enabled);
@@ -182,14 +167,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
() => settings.value.workflowCallerPolicyDefaultOption,
);
const isDefaultAuthenticationSaml = computed(
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml,
);
const isDefaultAuthenticationOidc = computed(
() => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Oidc,
);
const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []);
const isBelowUserQuota = computed(
@@ -210,21 +187,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
!!settings.value.userManagement.showSetupOnFirstLoad;
}
api.value = settings.value.publicApi;
if (settings.value.sso?.ldap) {
ldap.value.loginEnabled = settings.value.sso.ldap.loginEnabled;
ldap.value.loginLabel = settings.value.sso.ldap.loginLabel;
}
if (settings.value.sso?.saml) {
saml.value.loginEnabled = settings.value.sso.saml.loginEnabled;
saml.value.loginLabel = settings.value.sso.saml.loginLabel;
}
if (settings.value.sso?.oidc) {
oidc.value.loginEnabled = settings.value.sso.oidc.loginEnabled;
oidc.value.loginUrl = settings.value.sso.oidc.loginUrl || '';
oidc.value.callbackUrl = settings.value.sso.oidc.callbackUrl || '';
}
mfa.value.enabled = settings.value.mfa?.enabled;
folders.value.enabled = settings.value.folders?.enabled;
@@ -364,31 +326,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
templatesEndpointHealthy.value = true;
};
const getLdapConfig = async () => {
const rootStore = useRootStore();
return await ldapApi.getLdapConfig(rootStore.restApiContext);
};
const getLdapSynchronizations = async (pagination: { page: number }) => {
const rootStore = useRootStore();
return await ldapApi.getLdapSynchronizations(rootStore.restApiContext, pagination);
};
const testLdapConnection = async () => {
const rootStore = useRootStore();
return await ldapApi.testLdapConnection(rootStore.restApiContext);
};
const updateLdapConfig = async (ldapConfig: LdapConfig) => {
const rootStore = useRootStore();
return await ldapApi.updateLdapConfig(rootStore.restApiContext, ldapConfig);
};
const runLdapSync = async (data: IDataObject) => {
const rootStore = useRootStore();
return await ldapApi.runLdapSync(rootStore.restApiContext, data);
};
const getTimezones = async (): Promise<IDataObject> => {
const rootStore = useRootStore();
return await makeRestApiRequest(rootStore.restApiContext, 'GET', '/options/timezones');
@@ -412,8 +349,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
userManagement,
templatesEndpointHealthy,
api,
ldap,
saml,
mfa,
isDocker,
isDevRelease,
@@ -432,10 +367,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isPreviewMode,
publicApiLatestVersion,
publicApiPath,
isLdapLoginEnabled,
ldapLoginLabel,
isSamlLoginEnabled,
isOidcLoginEnabled,
showSetupPage,
deploymentType,
isCloudDeployment,
@@ -459,8 +390,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isQueueModeEnabled,
isMultiMain,
isWorkerViewAvailable,
isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
workflowCallerPolicyDefaultOption,
permanentlyDismissedBanners,
isBelowUserQuota,
@@ -474,13 +403,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
aiCreditsQuota,
experimental__minZoomNodeSettingsInCanvas,
partialExecutionVersion,
oidcCallBackUrl,
reset,
testLdapConnection,
getLdapConfig,
getLdapSynchronizations,
updateLdapConfig,
runLdapSync,
getTimezones,
testTemplatesEndpoint,
submitContactInfo,

View File

@@ -1,75 +1,19 @@
import type { OidcConfigDto } from '@n8n/api-types';
import { type SamlPreferences } from '@n8n/api-types';
import { computed, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { EnterpriseEditionFeature } from '@/constants';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useSettingsStore } from '@/stores/settings.store';
import * as ssoApi from '@n8n/rest-api-client/api/sso';
import type { SamlPreferencesExtractedData } from '@n8n/rest-api-client/api/sso';
import { updateCurrentUser } from '@/api/users';
import { useUsersStore } from '@/stores/users.store';
import * as ldapApi from '@n8n/rest-api-client/api/ldap';
import type { LdapConfig } from '@n8n/rest-api-client/api/ldap';
import type { IDataObject } from 'n8n-workflow';
import { UserManagementAuthenticationMethod } from '@/Interface';
export const useSSOStore = defineStore('sso', () => {
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const route = useRoute();
const state = reactive({
samlConfig: undefined as (SamlPreferences & SamlPreferencesExtractedData) | undefined,
oidcConfig: undefined as OidcConfigDto | undefined,
});
const samlConfig = computed(() => state.samlConfig);
const oidcConfig = computed(() => state.oidcConfig);
const isSamlLoginEnabled = computed({
get: () => settingsStore.isSamlLoginEnabled,
set: (value: boolean) => {
settingsStore.setSettings({
...settingsStore.settings,
sso: {
...settingsStore.settings.sso,
saml: {
...settingsStore.settings.sso.saml,
loginEnabled: value,
},
},
});
void toggleLoginEnabled(value);
},
});
const isOidcLoginEnabled = computed({
get: () => settingsStore.isOidcLoginEnabled,
set: (value: boolean) => {
settingsStore.setSettings({
...settingsStore.settings,
sso: {
...settingsStore.settings.sso,
oidc: {
...settingsStore.settings.sso.oidc,
loginEnabled: value,
},
},
});
},
});
const isEnterpriseSamlEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml],
);
const isEnterpriseOidcEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Oidc],
);
const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml);
const isDefaultAuthenticationOidc = computed(() => settingsStore.isDefaultAuthenticationOidc);
const authenticationMethod = ref<UserManagementAuthenticationMethod | undefined>(undefined);
const showSsoLoginButton = computed(
() =>
@@ -81,66 +25,201 @@ export const useSSOStore = defineStore('sso', () => {
isDefaultAuthenticationOidc.value),
);
const getSSORedirectUrl = async () =>
await ssoApi.initSSO(
rootStore.restApiContext,
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
);
const getSSORedirectUrl = async (existingRedirect?: string) =>
await ssoApi.initSSO(rootStore.restApiContext, existingRedirect);
const initialize = (options: {
authenticationMethod: UserManagementAuthenticationMethod;
config: {
ldap?: Pick<LdapConfig, 'loginLabel' | 'loginEnabled'>;
saml?: Pick<SamlPreferences, 'loginLabel' | 'loginEnabled'>;
oidc?: Pick<OidcConfigDto, 'loginEnabled'> & {
loginUrl?: string;
callbackUrl?: string;
};
};
features: {
saml: boolean;
ldap: boolean;
oidc: boolean;
};
}) => {
authenticationMethod.value = options.authenticationMethod;
isEnterpriseLdapEnabled.value = options.features.ldap;
if (options.config.ldap) {
ldap.value.loginEnabled = options.config.ldap.loginEnabled;
ldap.value.loginLabel = options.config.ldap.loginLabel;
}
isEnterpriseSamlEnabled.value = options.features.saml;
if (options.config.saml) {
saml.value.loginEnabled = options.config.saml.loginEnabled;
saml.value.loginLabel = options.config.saml.loginLabel;
}
isEnterpriseOidcEnabled.value = options.features.oidc;
if (options.config.oidc) {
oidc.value.loginEnabled = options.config.oidc.loginEnabled;
oidc.value.loginUrl = options.config.oidc.loginUrl || '';
oidc.value.callbackUrl = options.config.oidc.callbackUrl || '';
}
};
/**
* SAML
*/
const saml = ref<Pick<SamlPreferences, 'loginLabel' | 'loginEnabled'>>({
loginLabel: '',
loginEnabled: false,
});
const samlConfig = ref<SamlPreferences & SamlPreferencesExtractedData>();
const isSamlLoginEnabled = computed({
get: () => saml.value.loginEnabled,
set: (value: boolean) => {
saml.value.loginEnabled = value;
void toggleLoginEnabled(value);
},
});
const isEnterpriseSamlEnabled = ref(false);
const isDefaultAuthenticationSaml = computed(
() => 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 getOidcRedirectLUrl = async () => await ssoApi)
const getSamlConfig = async () => {
const samlConfig = await ssoApi.getSamlConfig(rootStore.restApiContext);
state.samlConfig = samlConfig;
return samlConfig;
const config = await ssoApi.getSamlConfig(rootStore.restApiContext);
samlConfig.value = config;
return config;
};
const saveSamlConfig = async (config: Partial<SamlPreferences>) =>
await ssoApi.saveSamlConfig(rootStore.restApiContext, config);
const testSamlConfig = async () => await ssoApi.testSamlConfig(rootStore.restApiContext);
const updateUser = async (params: { firstName: string; lastName: string }) =>
await updateCurrentUser(rootStore.restApiContext, {
email: usersStore.currentUser!.email!,
...params,
});
/**
* OIDC
*/
const userData = computed(() => usersStore.currentUser);
const oidc = ref<
Pick<OidcConfigDto, 'loginEnabled'> & {
loginUrl?: string;
callbackUrl?: string;
}
>({
loginUrl: '',
loginEnabled: false,
callbackUrl: '',
});
const oidcConfig = ref<OidcConfigDto | undefined>();
const isEnterpriseOidcEnabled = ref(false);
const getOidcConfig = async () => {
const oidcConfig = await ssoApi.getOidcConfig(rootStore.restApiContext);
state.oidcConfig = oidcConfig;
return oidcConfig;
const config = await ssoApi.getOidcConfig(rootStore.restApiContext);
oidcConfig.value = config;
return config;
};
const saveOidcConfig = async (config: OidcConfigDto) => {
const savedConfig = await ssoApi.saveOidcConfig(rootStore.restApiContext, config);
state.oidcConfig = savedConfig;
oidcConfig.value = savedConfig;
return savedConfig;
};
const isOidcLoginEnabled = computed({
get: () => oidc.value.loginEnabled,
set: (value: boolean) => {
oidc.value.loginEnabled = value;
},
});
const isDefaultAuthenticationOidc = computed(
() => authenticationMethod.value === UserManagementAuthenticationMethod.Oidc,
);
/**
* LDAP Configuration
*/
const ldap = ref<Pick<LdapConfig, 'loginLabel' | 'loginEnabled'>>({
loginLabel: '',
loginEnabled: false,
});
const isEnterpriseLdapEnabled = ref(false);
const isLdapLoginEnabled = computed(() => ldap.value.loginEnabled);
const ldapLoginLabel = computed(() => ldap.value.loginLabel);
const getLdapConfig = async () => {
const rootStore = useRootStore();
return await ldapApi.getLdapConfig(rootStore.restApiContext);
};
const getLdapSynchronizations = async (pagination: { page: number }) => {
const rootStore = useRootStore();
return await ldapApi.getLdapSynchronizations(rootStore.restApiContext, pagination);
};
const testLdapConnection = async () => {
const rootStore = useRootStore();
return await ldapApi.testLdapConnection(rootStore.restApiContext);
};
const updateLdapConfig = async (ldapConfig: LdapConfig) => {
const rootStore = useRootStore();
return await ldapApi.updateLdapConfig(rootStore.restApiContext, ldapConfig);
};
const runLdapSync = async (data: IDataObject) => {
const rootStore = useRootStore();
return await ldapApi.runLdapSync(rootStore.restApiContext, data);
};
return {
isEnterpriseOidcEnabled,
showSsoLoginButton,
getSSORedirectUrl,
initialize,
saml,
samlConfig,
isSamlLoginEnabled,
isOidcLoginEnabled,
isEnterpriseSamlEnabled,
isDefaultAuthenticationSaml,
isDefaultAuthenticationOidc,
showSsoLoginButton,
samlConfig,
oidcConfig,
userData,
getSSORedirectUrl,
getSamlMetadata,
getSamlConfig,
saveSamlConfig,
testSamlConfig,
updateUser,
oidc,
oidcConfig,
isOidcLoginEnabled,
isEnterpriseOidcEnabled,
isDefaultAuthenticationOidc,
getOidcConfig,
saveOidcConfig,
ldap,
isLdapLoginEnabled,
isEnterpriseLdapEnabled,
ldapLoginLabel,
getLdapConfig,
getLdapSynchronizations,
testLdapConnection,
updateLdapConfig,
runLdapSync,
};
});

View File

@@ -1,20 +1,13 @@
import { createPinia, setActivePinia } from 'pinia';
import { useSettingsStore } from '@/stores/settings.store';
import { useSSOStore } from '@/stores/sso.store';
import merge from 'lodash/merge';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import type { FrontendSettings } from '@n8n/api-types';
import type { UserManagementAuthenticationMethod } from '@/Interface';
let ssoStore: ReturnType<typeof useSSOStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
const DEFAULT_SETTINGS: FrontendSettings = SETTINGS_STORE_DEFAULT_STATE.settings;
describe('SSO store', () => {
beforeEach(() => {
setActivePinia(createPinia());
ssoStore = useSSOStore();
settingsStore = useSettingsStore();
});
test.each([
@@ -26,21 +19,19 @@ describe('SSO store', () => {
])(
'should check SSO login button availability when authenticationMethod is %s and enterprise feature is %s and sso login is set to %s',
(authenticationMethod, saml, loginEnabled, expectation) => {
settingsStore.setSettings(
merge({}, DEFAULT_SETTINGS, {
userManagement: {
authenticationMethod,
ssoStore.initialize({
authenticationMethod: authenticationMethod as UserManagementAuthenticationMethod,
config: {
saml: {
loginEnabled,
},
enterprise: {
saml,
},
sso: {
saml: {
loginEnabled,
},
},
}),
);
},
features: {
saml,
ldap: false,
oidc: false,
},
});
expect(ssoStore.showSsoLoginButton).toBe(expectation);
},

View File

@@ -264,6 +264,18 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
const updateUser = async (params: UserUpdateRequestDto) => {
const user = await usersApi.updateCurrentUser(rootStore.restApiContext, params);
addUsers([user]);
return user;
};
const updateUserName = async (params: { firstName: string; lastName: string }) => {
if (!currentUser.value) {
return;
}
return await updateUser({
email: currentUser.value.email as string,
...params,
});
};
const updateUserSettings = async (settings: SettingsUpdateRequestDto) => {
@@ -430,6 +442,7 @@ export const useUsersStore = defineStore(STORES.USERS, () => {
validatePasswordToken,
changePassword,
updateUser,
updateUserName,
updateUserSettings,
updateOtherUserSettings,
updateCurrentUserPassword,

View File

@@ -3,10 +3,10 @@ import { useRouter } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import merge from 'lodash/merge';
import SamlOnboarding from '@/views/SamlOnboarding.vue';
import { useSSOStore } from '@/stores/sso.store';
import { STORES } from '@n8n/stores';
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import { useUsersStore } from '@/stores/users.store';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -20,7 +20,7 @@ vi.mock('vue-router', () => {
});
let pinia: ReturnType<typeof createTestingPinia>;
let ssoStore: ReturnType<typeof useSSOStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let router: ReturnType<typeof useRouter>;
const renderComponent = createComponentRenderer(SamlOnboarding);
@@ -34,7 +34,7 @@ describe('SamlOnboarding', () => {
},
},
});
ssoStore = useSSOStore(pinia);
usersStore = useUsersStore(pinia);
router = useRouter();
});
@@ -43,7 +43,7 @@ describe('SamlOnboarding', () => {
});
it('should submit filled in form only and redirect', async () => {
vi.spyOn(ssoStore, 'updateUser').mockResolvedValue({
vi.spyOn(usersStore, 'updateUserName').mockResolvedValue({
id: '1',
isPending: false,
});
@@ -56,14 +56,14 @@ describe('SamlOnboarding', () => {
await userEvent.click(submit);
await waitAllPromises();
expect(ssoStore.updateUser).not.toHaveBeenCalled();
expect(usersStore.updateUserName).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
await userEvent.type(inputs[0], 'test');
await userEvent.type(inputs[1], 'test');
await userEvent.click(submit);
expect(ssoStore.updateUser).toHaveBeenCalled();
expect(usersStore.updateUserName).toHaveBeenCalled();
expect(router.push).toHaveBeenCalled();
});
});

View File

@@ -6,13 +6,13 @@ import AuthView from '@/views/AuthView.vue';
import { VIEWS } from '@/constants';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import { useSSOStore } from '@/stores/sso.store';
import { useUsersStore } from '@/stores/users.store';
const router = useRouter();
const locale = useI18n();
const toast = useToast();
const ssoStore = useSSOStore();
const usersStore = useUsersStore();
const loading = ref(false);
const FORM_CONFIG: IFormBoxConfig = reactive({
@@ -21,7 +21,7 @@ const FORM_CONFIG: IFormBoxConfig = reactive({
inputs: [
{
name: 'firstName',
initialValue: ssoStore.userData?.firstName,
initialValue: usersStore.currentUser?.firstName,
properties: {
label: locale.baseText('auth.firstName'),
maxlength: 32,
@@ -32,7 +32,7 @@ const FORM_CONFIG: IFormBoxConfig = reactive({
},
{
name: 'lastName',
initialValue: ssoStore.userData?.lastName,
initialValue: usersStore.currentUser?.lastName,
properties: {
label: locale.baseText('auth.lastName'),
maxlength: 32,
@@ -54,7 +54,7 @@ const onSubmit = async (values: { [key: string]: string }) => {
if (!isFormWithFirstAndLastName(values)) return;
try {
loading.value = true;
await ssoStore.updateUser(values);
await usersStore.updateUserName(values);
await router.push({ name: VIEWS.HOMEPAGE });
} catch (error) {
loading.value = false;

View File

@@ -19,6 +19,7 @@ import { createFormEventBus } from '@n8n/design-system/utils';
import type { TableColumnCtx } from 'element-plus';
import { useI18n } from '@n8n/i18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useSSOStore } from '@/stores/sso.store';
type TableRow = {
status: string;
@@ -65,6 +66,7 @@ const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
const settingsStore = useSettingsStore();
const ssoStore = useSSOStore();
const dataTable = ref<LdapSyncTable[]>([]);
const tableKey = ref(0);
@@ -192,7 +194,7 @@ const onSubmit = async () => {
hasAnyChanges.value = true;
}
adConfig.value = await settingsStore.updateLdapConfig(newConfiguration);
adConfig.value = await ssoStore.updateLdapConfig(newConfiguration);
toast.showToast({
title: i18n.baseText('settings.ldap.updateConfiguration'),
message: '',
@@ -214,7 +216,7 @@ const onSaveClick = () => {
const onTestConnectionClick = async () => {
loadingTestConnection.value = true;
try {
await settingsStore.testLdapConnection();
await ssoStore.testLdapConnection();
toast.showToast({
title: i18n.baseText('settings.ldap.connectionTest'),
message: i18n.baseText('settings.ldap.toast.connection.success'),
@@ -234,7 +236,7 @@ const onTestConnectionClick = async () => {
const onDryRunClick = async () => {
loadingDryRun.value = true;
try {
await settingsStore.runLdapSync({ type: 'dry' });
await ssoStore.runLdapSync({ type: 'dry' });
toast.showToast({
title: i18n.baseText('settings.ldap.runSync.title'),
message: i18n.baseText('settings.ldap.toast.sync.success'),
@@ -251,7 +253,7 @@ const onDryRunClick = async () => {
const onLiveRunClick = async () => {
loadingLiveRun.value = true;
try {
await settingsStore.runLdapSync({ type: 'live' });
await ssoStore.runLdapSync({ type: 'live' });
toast.showToast({
title: i18n.baseText('settings.ldap.runSync.title'),
message: i18n.baseText('settings.ldap.toast.sync.success'),
@@ -267,7 +269,7 @@ const onLiveRunClick = async () => {
const getLdapConfig = async () => {
try {
adConfig.value = await settingsStore.getLdapConfig();
adConfig.value = await ssoStore.getLdapConfig();
loginEnabled.value = adConfig.value.loginEnabled;
syncEnabled.value = adConfig.value.synchronizationEnabled;
const whenLoginEnabled: IFormInput['shouldDisplay'] = (values) => values.loginEnabled === true;
@@ -554,7 +556,7 @@ const getLdapConfig = async () => {
const getLdapSynchronizations = async (state: Parameters<Events['infinite']>[0]) => {
try {
loadingTable.value = true;
const data = await settingsStore.getLdapSynchronizations({
const data = await ssoStore.getLdapSynchronizations({
page: page.value,
});

View File

@@ -9,9 +9,12 @@ import { setupServer } from '@/__tests__/server';
import { ROLE } from '@n8n/api-types';
import { useUIStore } from '@/stores/ui.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSSOStore } from '@/stores/sso.store';
import { UserManagementAuthenticationMethod } from '@/Interface';
let pinia: ReturnType<typeof createPinia>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let ssoStore: ReturnType<typeof useSSOStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let uiStore: ReturnType<typeof useUIStore>;
let cloudPlanStore: ReturnType<typeof useCloudPlanStore>;
@@ -41,6 +44,7 @@ describe('SettingsPersonalView', () => {
pinia = createPinia();
settingsStore = useSettingsStore(pinia);
ssoStore = useSSOStore(pinia);
usersStore = useUsersStore(pinia);
uiStore = useUIStore(pinia);
cloudPlanStore = useCloudPlanStore(pinia);
@@ -49,6 +53,15 @@ describe('SettingsPersonalView', () => {
usersStore.currentUserId = currentUser.id;
await settingsStore.getSettings();
ssoStore.initialize({
authenticationMethod: UserManagementAuthenticationMethod.Email,
config: settingsStore.settings.sso,
features: {
saml: true,
ldap: true,
oidc: true,
},
});
});
afterAll(() => {
@@ -96,7 +109,9 @@ describe('SettingsPersonalView', () => {
});
it('should commit the theme change after clicking save', async () => {
vi.spyOn(usersStore, 'updateUser').mockReturnValue(Promise.resolve());
vi.spyOn(usersStore, 'updateUser').mockReturnValue(
Promise.resolve({ id: '123', isPending: false }),
);
const { getByPlaceholderText, findByText, getByTestId } = renderComponent({ pinia });
await waitAllPromises();
@@ -116,8 +131,8 @@ describe('SettingsPersonalView', () => {
describe('when external auth is enabled, email and password change', () => {
beforeEach(() => {
vi.spyOn(settingsStore, 'isSamlLoginEnabled', 'get').mockReturnValue(true);
vi.spyOn(settingsStore, 'isDefaultAuthenticationSaml', 'get').mockReturnValue(true);
vi.spyOn(ssoStore, 'isSamlLoginEnabled', 'get').mockReturnValue(true);
vi.spyOn(ssoStore, 'isDefaultAuthenticationSaml', 'get').mockReturnValue(true);
vi.spyOn(settingsStore, 'isMfaFeatureEnabled', 'get').mockReturnValue(true);
});

View File

@@ -19,6 +19,7 @@ import { createFormEventBus } from '@n8n/design-system/utils';
import type { MfaModalEvents } from '@/event-bus/mfa';
import { promptMfaCodeBus } from '@/event-bus/mfa';
import type { BaseTextKey } from '@n8n/i18n';
import { useSSOStore } from '@/stores/sso.store';
type UserBasicDetailsForm = {
firstName: string;
@@ -62,32 +63,38 @@ const themeOptions = ref<Array<{ name: ThemeOption; label: BaseTextKey }>>([
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const ssoStore = useSSOStore();
const cloudPlanStore = useCloudPlanStore();
const currentUser = computed((): IUser | null => {
return usersStore.currentUser;
});
const isExternalAuthEnabled = computed((): boolean => {
const isLdapEnabled =
settingsStore.settings.enterprise.ldap && currentUser.value?.signInType === 'ldap';
const isSamlEnabled =
settingsStore.isSamlLoginEnabled && settingsStore.isDefaultAuthenticationSaml;
ssoStore.isEnterpriseLdapEnabled && currentUser.value?.signInType === 'ldap';
const isSamlEnabled = ssoStore.isSamlLoginEnabled && ssoStore.isDefaultAuthenticationSaml;
const isOidcEnabled =
settingsStore.settings.enterprise.oidc && currentUser.value?.signInType === 'oidc';
ssoStore.isEnterpriseOidcEnabled && currentUser.value?.signInType === 'oidc';
return isLdapEnabled || isSamlEnabled || isOidcEnabled;
});
const isPersonalSecurityEnabled = computed((): boolean => {
return usersStore.isInstanceOwner || !isExternalAuthEnabled.value;
});
const mfaDisabled = computed((): boolean => {
return !usersStore.mfaEnabled;
});
const isMfaFeatureEnabled = computed((): boolean => {
return settingsStore.isMfaFeatureEnabled;
});
const hasAnyPersonalisationChanges = computed((): boolean => {
return currentSelectedTheme.value !== uiStore.theme;
});
const hasAnyChanges = computed(() => {
return hasAnyBasicInfoChanges.value || hasAnyPersonalisationChanges.value;
});

View File

@@ -9,7 +9,6 @@ import { useSettingsStore } from '@/stores/settings.store';
import userEvent from '@testing-library/user-event';
import { useSSOStore } from '@/stores/sso.store';
import { createComponentRenderer } from '@/__tests__/render';
import { EnterpriseEditionFeature } from '@/constants';
import { nextTick } from 'vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import type { SamlPreferencesExtractedData } from '@n8n/rest-api-client/api/sso';
@@ -328,7 +327,7 @@ describe('SettingsSso', () => {
});
it('should render licensed content', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Saml] = true;
ssoStore.isEnterpriseSamlEnabled = true;
await nextTick();
const { getByTestId, queryByTestId, getByRole } = renderComponent({
@@ -342,7 +341,7 @@ describe('SettingsSso', () => {
it('should enable activation checkbox and test button if data is already saved', async () => {
await ssoStore.getSamlConfig();
settingsStore.settings.enterprise[EnterpriseEditionFeature.Saml] = true;
ssoStore.isEnterpriseSamlEnabled = true;
await nextTick();
const { container, getByTestId, getByRole } = renderComponent({

View File

@@ -10,7 +10,6 @@ import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useRootStore } from '@n8n/stores/useRootStore';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { MODAL_CONFIRM } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
type SupportedProtocolType = (typeof SupportedProtocols)[keyof typeof SupportedProtocols];
@@ -29,7 +28,6 @@ const telemetry = useTelemetry();
const rootStore = useRootStore();
const ssoStore = useSSOStore();
const message = useMessage();
const settingsStore = useSettingsStore();
const toast = useToast();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
@@ -398,7 +396,7 @@ async function onOidcSettingsSave() {
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
:value="settingsStore.oidcCallBackUrl"
:value="ssoStore.oidc.callbackUrl"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
toast-title="Redirect URL copied to clipboard"
/>

View File

@@ -91,8 +91,7 @@ describe('SettingsUsersView', () => {
it('hides invite button visibility based on user permissions', async () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const userStore = useUsersStore(pinia);
// @ts-expect-error: mocked getter
const userStore = mockedStore(useUsersStore);
userStore.currentUser = createUser({ isDefaultUser: true });
const { queryByTestId } = renderView({ pinia });
@@ -103,8 +102,7 @@ describe('SettingsUsersView', () => {
describe('Below quota', () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
const settingsStore = mockedStore(useSettingsStore);
settingsStore.isBelowUserQuota = false;
it('disables the invite button', async () => {
@@ -180,8 +178,7 @@ describe('SettingsUsersView', () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
const settingsStore = mockedStore(useSettingsStore);
settingsStore.isSmtpSetup = true;
const userStore = useUsersStore();
@@ -236,9 +233,8 @@ describe('SettingsUsersView', () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isSamlLoginEnabled = true;
const ssoStore = useSSOStore(pinia);
ssoStore.isSamlLoginEnabled = true;
const userStore = useUsersStore();
@@ -256,9 +252,8 @@ describe('SettingsUsersView', () => {
const pinia = createTestingPinia({ initialState: getInitialState() });
const settingsStore = useSettingsStore(pinia);
// @ts-expect-error: mocked getter
settingsStore.isSamlLoginEnabled = true;
const ssoStore = useSSOStore(pinia);
ssoStore.isSamlLoginEnabled = true;
const userStore = useUsersStore();

View File

@@ -71,13 +71,12 @@ const usersListActions = computed((): IUserListAction[] => {
{
label: i18n.baseText('settings.users.actions.allowSSOManualLogin'),
value: 'allowSSOManualLogin',
guard: (user) => settingsStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin,
guard: (user) => !!ssoStore.isSamlLoginEnabled && !user.settings?.allowSSOManualLogin,
},
{
label: i18n.baseText('settings.users.actions.disallowSSOManualLogin'),
value: 'disallowSSOManualLogin',
guard: (user) =>
settingsStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true,
guard: (user) => !!ssoStore.isSamlLoginEnabled && user.settings?.allowSSOManualLogin === true,
},
];
});

View File

@@ -12,6 +12,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSSOStore } from '@/stores/sso.store';
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/constants';
@@ -27,6 +28,7 @@ export type MfaCodeOrMfaRecoveryCode = Pick<LoginRequestDto, 'mfaCode' | 'mfaRec
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const ssoStore = useSSOStore();
const route = useRoute();
const router = useRouter();
@@ -41,8 +43,8 @@ const emailOrLdapLoginId = ref('');
const password = ref('');
const reportError = ref(false);
const ldapLoginLabel = computed(() => settingsStore.ldapLoginLabel);
const isLdapLoginEnabled = computed(() => settingsStore.isLdapLoginEnabled);
const ldapLoginLabel = computed(() => ssoStore.ldapLoginLabel);
const isLdapLoginEnabled = computed(() => ssoStore.isLdapLoginEnabled);
const emailLabel = computed(() => {
let label = locale.baseText('auth.email');
if (isLdapLoginEnabled.value && ldapLoginLabel.value) {