diff --git a/packages/frontend/editor-ui/src/components/SSOLogin.vue b/packages/frontend/editor-ui/src/components/SSOLogin.vue index abe8361f08..fa26cec2bd 100644 --- a/packages/frontend/editor-ui/src/components/SSOLogin.vue +++ b/packages/frontend/editor-ui/src/components/SSOLogin.vue @@ -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); } diff --git a/packages/frontend/editor-ui/src/init.test.ts b/packages/frontend/editor-ui/src/init.test.ts index 9a201d3950..eb6180fea9 100644 --- a/packages/frontend/editor-ui/src/init.test.ts +++ b/packages/frontend/editor-ui/src/init.test.ts @@ -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; let nodeTypesStore: ReturnType; let versionsStore: ReturnType; + let ssoStore: ReturnType; 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()', () => { diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/init.ts index e66d781b8f..430953e99e 100644 --- a/packages/frontend/editor-ui/src/init.ts +++ b/packages/frontend/editor-ui/src/init.ts @@ -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; } /** diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index 9bbe8791b5..df9633a685 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -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 => { 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, diff --git a/packages/frontend/editor-ui/src/stores/sso.store.ts b/packages/frontend/editor-ui/src/stores/sso.store.ts index 62f138021e..6ffac5775b 100644 --- a/packages/frontend/editor-ui/src/stores/sso.store.ts +++ b/packages/frontend/editor-ui/src/stores/sso.store.ts @@ -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(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; + saml?: Pick; + oidc?: Pick & { + 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>({ + loginLabel: '', + loginEnabled: false, + }); + + const samlConfig = ref(); + + 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) => 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 & { + loginUrl?: string; + callbackUrl?: string; + } + >({ + loginUrl: '', + loginEnabled: false, + callbackUrl: '', + }); + + const oidcConfig = ref(); + + 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>({ + 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, }; }); diff --git a/packages/frontend/editor-ui/src/stores/sso.test.ts b/packages/frontend/editor-ui/src/stores/sso.test.ts index f777e55c3d..389b486cd3 100644 --- a/packages/frontend/editor-ui/src/stores/sso.test.ts +++ b/packages/frontend/editor-ui/src/stores/sso.test.ts @@ -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; -let settingsStore: ReturnType; - -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); }, diff --git a/packages/frontend/editor-ui/src/stores/users.store.ts b/packages/frontend/editor-ui/src/stores/users.store.ts index 49d573d754..958d17c304 100644 --- a/packages/frontend/editor-ui/src/stores/users.store.ts +++ b/packages/frontend/editor-ui/src/stores/users.store.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/views/SamlOnboarding.test.ts b/packages/frontend/editor-ui/src/views/SamlOnboarding.test.ts index 4090c945de..1774badb35 100644 --- a/packages/frontend/editor-ui/src/views/SamlOnboarding.test.ts +++ b/packages/frontend/editor-ui/src/views/SamlOnboarding.test.ts @@ -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; -let ssoStore: ReturnType; +let usersStore: ReturnType; let router: ReturnType; 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(); }); }); diff --git a/packages/frontend/editor-ui/src/views/SamlOnboarding.vue b/packages/frontend/editor-ui/src/views/SamlOnboarding.vue index ca057381f4..a3fdf7ed83 100644 --- a/packages/frontend/editor-ui/src/views/SamlOnboarding.vue +++ b/packages/frontend/editor-ui/src/views/SamlOnboarding.vue @@ -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; diff --git a/packages/frontend/editor-ui/src/views/SettingsLdapView.vue b/packages/frontend/editor-ui/src/views/SettingsLdapView.vue index f2c4a8b199..4f0234ffd1 100644 --- a/packages/frontend/editor-ui/src/views/SettingsLdapView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsLdapView.vue @@ -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([]); 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[0]) => { try { loadingTable.value = true; - const data = await settingsStore.getLdapSynchronizations({ + const data = await ssoStore.getLdapSynchronizations({ page: page.value, }); diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts b/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts index a3f9fc6227..2c65b155b2 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.test.ts @@ -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; let settingsStore: ReturnType; +let ssoStore: ReturnType; let usersStore: ReturnType; let uiStore: ReturnType; let cloudPlanStore: ReturnType; @@ -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); }); diff --git a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue index 1bd4f43368..b94c62a2d4 100644 --- a/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsPersonalView.vue @@ -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>([ 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; }); diff --git a/packages/frontend/editor-ui/src/views/SettingsSso.test.ts b/packages/frontend/editor-ui/src/views/SettingsSso.test.ts index 1693fdfc00..68287e6925 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSso.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsSso.test.ts @@ -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({ diff --git a/packages/frontend/editor-ui/src/views/SettingsSso.vue b/packages/frontend/editor-ui/src/views/SettingsSso.vue index 35c00628ab..5d6c15e7a4 100644 --- a/packages/frontend/editor-ui/src/views/SettingsSso.vue +++ b/packages/frontend/editor-ui/src/views/SettingsSso.vue @@ -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() {
diff --git a/packages/frontend/editor-ui/src/views/SettingsUsersView.test.ts b/packages/frontend/editor-ui/src/views/SettingsUsersView.test.ts index 4a5f2b3bca..49d276286c 100644 --- a/packages/frontend/editor-ui/src/views/SettingsUsersView.test.ts +++ b/packages/frontend/editor-ui/src/views/SettingsUsersView.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/views/SettingsUsersView.vue b/packages/frontend/editor-ui/src/views/SettingsUsersView.vue index 81df5a51cd..587d5cf32b 100644 --- a/packages/frontend/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/frontend/editor-ui/src/views/SettingsUsersView.vue @@ -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, }, ]; }); diff --git a/packages/frontend/editor-ui/src/views/SigninView.vue b/packages/frontend/editor-ui/src/views/SigninView.vue index d952e0e2b7..9b4d7919bd 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.vue +++ b/packages/frontend/editor-ui/src/views/SigninView.vue @@ -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 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) {