From 5d80757ddff8693975f94476c9f11725c065e1d6 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Tue, 4 Apr 2023 14:28:29 +0200 Subject: [PATCH] feat(editor): SSO setup (#5736) * feat(editor): SSO settings page * feat(editor): SSO settings page * feat(editor): SSO settings page * feat(editor): SSO settings page * feat(editor): SSO settings page * feat(editor): SSO settings page * Merge remote-tracking branch 'origin/master' into pay-170-sso-set-up-page # Conflicts: # packages/cli/src/sso/saml/routes/saml.controller.ee.ts * feat(editor): Prevent SSO settings page route * feat(editor): some UI improvements * fix(editor): SSO settings saml config optional chaining * fix return values saml controller * fix(editor): drop dompurify * fix(editor): save xml as is * return authenticationMethod with settings * fix(editor): add missing prop to server * chore(editor): code formatting * fix ldap/saml enable toggle endpoint * fix missing import * prevent faulty ldap setting from breaking startup * remove sso fake-door from users page * fix(editor): update SSO settings route permissions + unit testing * fix(editor): update vite config for test * fix(editor): add paddings to SSO settings page buttons, add translation * fix(editor): fix saml unit test * fix(core): Improve saml test connection function (#5899) improve-saml-test-connection return --------- Co-authored-by: Michael Auerswald Co-authored-by: Romain Minaud --- packages/cli/src/Interfaces.ts | 2 + packages/cli/src/Ldap/helpers.ts | 40 +- packages/cli/src/Server.ts | 3 + packages/cli/src/sso/saml/constants.ts | 6 +- .../src/sso/saml/routes/saml.controller.ee.ts | 28 +- packages/cli/src/sso/saml/saml.service.ee.ts | 28 +- packages/cli/src/sso/saml/samlHelpers.ts | 25 +- .../cli/src/sso/saml/serviceProvider.ee.ts | 5 + .../cli/src/sso/saml/types/samlPreferences.ts | 4 + .../test/integration/saml/saml.api.test.ts | 2 +- packages/editor-ui/src/Interface.ts | 41 + packages/editor-ui/src/api/sso.ts | 35 +- .../src/components/SettingsSidebar.vue | 16 + packages/editor-ui/src/constants.ts | 1 + .../src/plugins/i18n/locales/en.json | 19 +- packages/editor-ui/src/plugins/icons.ts | 2 + packages/editor-ui/src/router.ts | 1007 +++++++++-------- packages/editor-ui/src/stores/sso.ts | 39 +- .../src/utils/__tests__/userUtils.test.ts | 124 ++ packages/editor-ui/src/utils/typeHelpers.ts | 1 + packages/editor-ui/src/views/SettingsSso.vue | 174 +++ .../editor-ui/src/views/SettingsUsersView.vue | 14 +- packages/editor-ui/vite.config.ts | 10 + 23 files changed, 1066 insertions(+), 560 deletions(-) create mode 100644 packages/editor-ui/src/utils/__tests__/userUtils.test.ts create mode 100644 packages/editor-ui/src/utils/typeHelpers.ts create mode 100644 packages/editor-ui/src/views/SettingsSso.vue diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 1d9fb69633886..4d14218ecf7c4 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -24,6 +24,7 @@ import type { IExecutionsSummary, FeatureFlags, WorkflowSettings, + AuthenticationMethod, } from 'n8n-workflow'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -552,6 +553,7 @@ export interface IUserManagementSettings { enabled: boolean; showSetupOnFirstLoad?: boolean; smtpSetup: boolean; + authenticationMethod: AuthenticationMethod; } export interface IActiveDirectorySettings { enabled: boolean; diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 0785f7fdd1fb5..21779d21a03ac 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -26,10 +26,12 @@ import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow'; import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; import { + getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isLdapCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '@/sso/ssoHelpers'; +import { InternalServerError } from '../ResponseHelper'; /** * Check whether the LDAP feature is disabled in the instance @@ -54,25 +56,21 @@ export const setLdapLoginLabel = (value: string): void => { /** * Set the LDAP login enabled to the configuration object */ -export const setLdapLoginEnabled = async (value: boolean): Promise => { - if (config.get(LDAP_LOGIN_ENABLED) === value) { - return; - } - // only one auth method can be active at a time, with email being the default - if (value && isEmailCurrentAuthenticationMethod()) { - // enable ldap login and disable email login, but only if email is the current auth method - config.set(LDAP_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('ldap'); - } else if (!value && isLdapCurrentAuthenticationMethod()) { - // disable ldap login, but only if ldap is the current auth method - config.set(LDAP_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); +export async function setLdapLoginEnabled(enabled: boolean): Promise { + if (isEmailCurrentAuthenticationMethod() || isLdapCurrentAuthenticationMethod()) { + if (enabled) { + config.set(LDAP_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('ldap'); + } else if (!enabled) { + config.set(LDAP_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } } else { - Logger.warn( - 'Cannot switch LDAP login enabled state when an authentication method other than email is active', + throw new InternalServerError( + `Cannot switch LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, ); } -}; +} /** * Retrieve the LDAP login label from the configuration object @@ -217,7 +215,15 @@ export const handleLdapInit = async (): Promise => { const ldapConfig = await getLdapConfig(); - await setGlobalLdapConfigVariables(ldapConfig); + try { + await setGlobalLdapConfigVariables(ldapConfig); + } catch (error) { + Logger.error( + `Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + error, + ); + } // init LDAP manager with the current // configuration diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 1609b3ba286e0..3a6ffaad6de48 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -157,6 +157,7 @@ import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/sam import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; import { LdapManager } from './Ldap/LdapManager.ee'; +import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; const exec = promisify(callbackExec); @@ -269,6 +270,7 @@ class Server extends AbstractServer { config.getEnv('userManagement.isInstanceOwnerSetUp') === false && config.getEnv('userManagement.skipInstanceOwnerSetup') === false, smtpSetup: isEmailSetUp(), + authenticationMethod: getCurrentAuthenticationMethod(), }, sso: { saml: { @@ -328,6 +330,7 @@ class Server extends AbstractServer { // refresh user management status Object.assign(this.frontendSettings.userManagement, { enabled: isUserManagementEnabled(), + authenticationMethod: getCurrentAuthenticationMethod(), showSetupOnFirstLoad: config.getEnv('userManagement.disabled') === false && config.getEnv('userManagement.isInstanceOwnerSetUp') === false && diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts index 3729f3ce51660..4b1bca0fce510 100644 --- a/packages/cli/src/sso/saml/constants.ts +++ b/packages/cli/src/sso/saml/constants.ts @@ -3,8 +3,6 @@ export class SamlUrls { static readonly initSSO = '/initsso'; - static readonly restInitSSO = this.samlRESTRoot + this.initSSO; - static readonly acs = '/acs'; static readonly restAcs = this.samlRESTRoot + this.acs; @@ -17,9 +15,9 @@ export class SamlUrls { static readonly configTest = '/config/test'; - static readonly configToggleEnabled = '/config/toggle'; + static readonly configTestReturn = '/config/test/return'; - static readonly restConfig = this.samlRESTRoot + this.config; + static readonly configToggleEnabled = '/config/toggle'; static readonly defaultRedirect = '/'; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 28b9d3c5223f9..731d67f3753df 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -16,7 +16,11 @@ import type { PostBindingContext } from 'samlify/types/src/entity'; import { isSamlLicensedAndEnabled } from '../samlHelpers'; import type { SamlLoginBinding } from '../types'; import { AuthenticatedRequest } from '@/requests'; -import { getServiceProviderEntityId, getServiceProviderReturnUrl } from '../serviceProvider.ee'; +import { + getServiceProviderConfigTestReturnUrl, + getServiceProviderEntityId, + getServiceProviderReturnUrl, +} from '../serviceProvider.ee'; @RestController('/sso/saml') export class SamlController { @@ -34,13 +38,13 @@ export class SamlController { * Return SAML config */ @Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) - async configGet(req: AuthenticatedRequest, res: express.Response) { + async configGet() { const prefs = this.samlService.samlPreferences; - return res.send({ + return { ...prefs, entityID: getServiceProviderEntityId(), returnUrl: getServiceProviderReturnUrl(), - }); + }; } /** @@ -48,11 +52,11 @@ export class SamlController { * Set SAML config */ @Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) - async configPost(req: SamlConfiguration.Update, res: express.Response) { + async configPost(req: SamlConfiguration.Update) { const validationResult = await validate(req.body); if (validationResult.length === 0) { const result = await this.samlService.setSamlPreferences(req.body); - return res.send(result); + return result; } else { throw new BadRequestError( 'Body is not a valid SamlPreferences object: ' + @@ -100,6 +104,10 @@ export class SamlController { private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { const loginResult = await this.samlService.handleSamlLogin(req, binding); if (loginResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (req.body.RelayState && req.body.RelayState === getServiceProviderConfigTestReturnUrl()) { + return res.status(202).send(loginResult.attributes); + } if (loginResult.authenticatedUser) { // Only sign in user if SAML is enabled, otherwise treat as test connection if (isSamlLicensedAndEnabled()) { @@ -134,13 +142,13 @@ export class SamlController { */ @Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] }) async configTestGet(req: AuthenticatedRequest, res: express.Response) { - return this.handleInitSSO(res); + return this.handleInitSSO(res, getServiceProviderConfigTestReturnUrl()); } - private async handleInitSSO(res: express.Response) { - const result = this.samlService.getLoginRequestUrl(); + private async handleInitSSO(res: express.Response, relayState?: string) { + const result = this.samlService.getLoginRequestUrl(relayState); if (result?.binding === 'redirect') { - return res.send(result.context.context); + return result.context.context; } else if (result?.binding === 'post') { return res.send(getInitSSOFormView(result.context as PostBindingContext)); } else { diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 1bf8e36b9ffc2..436aae8e03404 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -20,12 +20,13 @@ import { setSamlLoginLabel, updateUserFromSamlAttributes, } from './samlHelpers'; -import type { Settings } from '../../databases/entities/Settings'; +import type { Settings } from '@/databases/entities/Settings'; import axios from 'axios'; import https from 'https'; import type { SamlLoginBinding } from './types'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import { validateMetadata, validateResponse } from './samlValidator'; +import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; @Service() export class SamlService { @@ -48,6 +49,7 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, + relayState: getInstanceBaseUrl(), signatureConfig: { prefix: 'ds', location: { @@ -92,7 +94,10 @@ export class SamlService { return getServiceProviderInstance(this._samlPreferences); } - getLoginRequestUrl(binding?: SamlLoginBinding): { + getLoginRequestUrl( + relayState?: string, + binding?: SamlLoginBinding, + ): { binding: SamlLoginBinding; context: BindingContext | PostBindingContext; } { @@ -100,28 +105,29 @@ export class SamlService { if (binding === 'post') { return { binding, - context: this.getPostLoginRequestUrl(), + context: this.getPostLoginRequestUrl(relayState), }; } else { return { binding, - context: this.getRedirectLoginRequestUrl(), + context: this.getRedirectLoginRequestUrl(relayState), }; } } - private getRedirectLoginRequestUrl(): BindingContext { - const loginRequest = this.getServiceProviderInstance().createLoginRequest( - this.getIdentityProviderInstance(), - 'redirect', - ); + private getRedirectLoginRequestUrl(relayState?: string): BindingContext { + const sp = this.getServiceProviderInstance(); + sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + const loginRequest = sp.createLoginRequest(this.getIdentityProviderInstance(), 'redirect'); //TODO:SAML: debug logging LoggerProxy.debug(loginRequest.context); return loginRequest; } - private getPostLoginRequestUrl(): PostBindingContext { - const loginRequest = this.getServiceProviderInstance().createLoginRequest( + private getPostLoginRequestUrl(relayState?: string): PostBindingContext { + const sp = this.getServiceProviderInstance(); + sp.entitySetting.relayState = relayState ?? getInstanceBaseUrl(); + const loginRequest = sp.createLoginRequest( this.getIdentityProviderInstance(), 'post', ) as PostBindingContext; diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index 5975c02653c34..e401d0457a30d 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -4,7 +4,7 @@ import * as Db from '@/Db'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { User } from '@db/entities/User'; import { License } from '@/License'; -import { AuthError } from '@/ResponseHelper'; +import { AuthError, InternalServerError } from '@/ResponseHelper'; import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import type { SamlPreferences } from './types/samlPreferences'; import type { SamlUserAttributes } from './types/samlUserAttributes'; @@ -12,11 +12,11 @@ import type { FlowResult } from 'samlify/types/src/flow'; import type { SamlAttributeMapping } from './types/samlAttributeMapping'; import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; import { + getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '../ssoHelpers'; -import { LoggerProxy } from 'n8n-workflow'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -30,18 +30,17 @@ export function getSamlLoginLabel(): string { // can only toggle between email and saml, not directly to e.g. ldap export async function setSamlLoginEnabled(enabled: boolean): Promise { - if (config.get(SAML_LOGIN_ENABLED) === enabled) { - return; - } - if (enabled && isEmailCurrentAuthenticationMethod()) { - config.set(SAML_LOGIN_ENABLED, true); - await setCurrentAuthenticationMethod('saml'); - } else if (!enabled && isSamlCurrentAuthenticationMethod()) { - config.set(SAML_LOGIN_ENABLED, false); - await setCurrentAuthenticationMethod('email'); + if (isEmailCurrentAuthenticationMethod() || isSamlCurrentAuthenticationMethod()) { + if (enabled) { + config.set(SAML_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('saml'); + } else if (!enabled) { + config.set(SAML_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } } else { - LoggerProxy.warn( - 'Cannot switch SAML login enabled state when an authentication method other than email is active', + throw new InternalServerError( + `Cannot switch SAML login enabled state when an authentication method other than email or saml is active (current: ${getCurrentAuthenticationMethod()})`, ); } } diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index 5d992830120a0..f6d707eaf86be 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -15,6 +15,10 @@ export function getServiceProviderReturnUrl(): string { return getInstanceBaseUrl() + SamlUrls.restAcs; } +export function getServiceProviderConfigTestReturnUrl(): string { + return getInstanceBaseUrl() + SamlUrls.configTestReturn; +} + // TODO:SAML: make these configurable for the end user export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { if (serviceProviderInstance === undefined) { @@ -24,6 +28,7 @@ export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProvi wantAssertionsSigned: prefs.wantAssertionsSigned, wantMessageSigned: prefs.wantMessageSigned, signatureConfig: prefs.signatureConfig, + relayState: prefs.relayState, nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'], assertionConsumerService: [ { diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index c5c72bcd0f85e..da02f1ebc4cef 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -57,4 +57,8 @@ export class SamlPreferences { action: 'after', }, }; + + @IsString() + @IsOptional() + relayState?: string = ''; } diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index d02bd95c68ac8..40f19995c1305 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -127,7 +127,7 @@ describe('Instance owner', () => { .send({ loginEnabled: true, }) - .expect(200); + .expect(500); expect(getCurrentAuthenticationMethod()).toBe('ldap'); }); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index d6610f7611ce1..659b5d97c73be 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -37,6 +37,7 @@ import { import { SignInType } from './constants'; import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants'; import { BulkCommand, Undoable } from '@/models/history'; +import { PartialBy } from '@/utils/typeHelpers'; export * from 'n8n-design-system/types'; @@ -1484,3 +1485,43 @@ export type ExecutionsQueryFilter = { startedAfter?: string; startedBefore?: string; }; + +export type SamlAttributeMapping = { + email: string; + firstName: string; + lastName: string; + userPrincipalName: string; +}; + +export type SamlLoginBinding = 'post' | 'redirect'; + +export type SamlSignatureConfig = { + prefix: 'ds'; + location: { + reference: '/samlp:Response/saml:Issuer'; + action: 'after'; + }; +}; + +export type SamlPreferencesLoginEnabled = { + loginEnabled: boolean; +}; + +export type SamlPreferences = { + mapping?: SamlAttributeMapping; + metadata?: string; + metadataUrl?: string; + ignoreSSL?: boolean; + loginBinding?: SamlLoginBinding; + acsBinding?: SamlLoginBinding; + authnRequestsSigned?: boolean; + loginLabel?: string; + wantAssertionsSigned?: boolean; + wantMessageSigned?: boolean; + signatureConfig?: SamlSignatureConfig; +} & PartialBy; + +export type SamlPreferencesExtractedData = { + entityID: string; + returnUrl: string; +}; diff --git a/packages/editor-ui/src/api/sso.ts b/packages/editor-ui/src/api/sso.ts index 5019335d35eb3..5fc90c8a2e4e9 100644 --- a/packages/editor-ui/src/api/sso.ts +++ b/packages/editor-ui/src/api/sso.ts @@ -1,6 +1,39 @@ import { makeRestApiRequest } from '@/utils'; -import { IRestApiContext } from '@/Interface'; +import { + IRestApiContext, + SamlPreferencesLoginEnabled, + SamlPreferences, + SamlPreferencesExtractedData, +} from '@/Interface'; export const initSSO = (context: IRestApiContext): Promise => { return makeRestApiRequest(context, 'GET', '/sso/saml/initsso'); }; + +export const getSamlMetadata = (context: IRestApiContext): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/metadata'); +}; + +export const getSamlConfig = ( + context: IRestApiContext, +): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/config'); +}; + +export const saveSamlConfig = ( + context: IRestApiContext, + data: SamlPreferences, +): Promise => { + return makeRestApiRequest(context, 'POST', '/sso/saml/config', data); +}; + +export const toggleSamlConfig = ( + context: IRestApiContext, + data: SamlPreferencesLoginEnabled, +): Promise => { + return makeRestApiRequest(context, 'POST', '/sso/saml/config/toggle', data); +}; + +export const testSamlConfig = (context: IRestApiContext): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/config/test'); +}; diff --git a/packages/editor-ui/src/components/SettingsSidebar.vue b/packages/editor-ui/src/components/SettingsSidebar.vue index 02dfe188bf1ed..bd9c380a1ad28 100644 --- a/packages/editor-ui/src/components/SettingsSidebar.vue +++ b/packages/editor-ui/src/components/SettingsSidebar.vue @@ -74,6 +74,14 @@ export default mixins(userHelpers, pushConnection).extend({ available: this.canAccessApiSettings(), activateOnRouteNames: [VIEWS.API_SETTINGS], }, + { + id: 'settings-sso', + icon: 'user-lock', + label: this.$locale.baseText('settings.sso'), + position: 'top', + available: this.canAccessSso(), + activateOnRouteNames: [VIEWS.SSO_SETTINGS], + }, { id: 'settings-ldap', icon: 'network-wired', @@ -143,6 +151,9 @@ export default mixins(userHelpers, pushConnection).extend({ canAccessUsageAndPlan(): boolean { return this.canUserAccessRouteByName(VIEWS.USAGE); }, + canAccessSso(): boolean { + return this.canUserAccessRouteByName(VIEWS.SSO_SETTINGS); + }, onVersionClick() { this.uiStore.openModal(ABOUT_MODAL_KEY); }, @@ -191,6 +202,11 @@ export default mixins(userHelpers, pushConnection).extend({ this.$router.push({ name: VIEWS.USAGE }); } break; + case 'settings-sso': + if (this.$router.currentRoute.name !== VIEWS.SSO_SETTINGS) { + this.$router.push({ name: VIEWS.SSO_SETTINGS }); + } + break; default: break; } diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 6e53986a4b370..b611b0a436ae8 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -388,6 +388,7 @@ export enum VIEWS { WORKFLOW_EXECUTIONS = 'WorkflowExecutions', USAGE = 'Usage', LOG_STREAMING_SETTINGS = 'LogStreamingSettingsView', + SSO_SETTINGS = 'SSoSettings', } export enum FAKE_DOOR_FEATURES { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 179be986b01f6..45e39b871ca46 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1719,7 +1719,24 @@ "settings.ldap.form.searchTimeout.label": "Search Timeout (Seconds)", "settings.ldap.form.searchTimeout.infoText": "The timeout value for queries to the AD/LDAP server. Increase if you are getting timeout errors caused by a slow AD/LDAP server", "settings.ldap.section.synchronization.title": "Synchronization", - + "settings.sso": "SSO", + "settings.sso.title": "Single Sign On", + "settings.sso.subtitle": "SAML 2.0", + "settings.sso.info": "SAML SSO (Security Assertion Markup Language Single Sign-On) is a type of authentication process that enables users to access multiple applications with a single set of login credentials. {link}", + "settings.sso.info.link": "More info.", + "settings.sso.activation.tooltip": "You need to save the settings first before activating SAML", + "settings.sso.activated": "Activated", + "settings.sso.deactivated": "Deactivated", + "settings.sso.settings.redirectUrl.label": "Redirect URL", + "settings.sso.settings.redirectUrl.copied": "Redirect URL copied to clipboard", + "settings.sso.settings.redirectUrl.help": "Save the Redirect URL as you’ll need it to configure these in the SAML provider’s settings.", + "settings.sso.settings.entityId.label": "Entity ID", + "settings.sso.settings.entityId.copied": "Entity ID copied to clipboard", + "settings.sso.settings.entityId.help": "Save the Entity URL as you’ll need it to configure these in the SAML provider’s settings.", + "settings.sso.settings.ips.label": "Identity Provider Settings", + "settings.sso.settings.ips.help": "Add the raw Metadata XML provided by your Identity Provider", + "settings.sso.settings.test": "Test settings", + "settings.sso.settings.save": "Save settings", "sso.login.divider": "or", "sso.login.button": "Continue with SSO" } diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons.ts index 3d67c20fc2d42..8c0a82d4db008 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons.ts @@ -125,6 +125,7 @@ import { faVideo, faTree, faStickyNote as faSolidStickyNote, + faUserLock, } from '@fortawesome/free-solid-svg-icons'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; @@ -258,5 +259,6 @@ addIcon(faUserFriends); addIcon(faUsers); addIcon(faVideo); addIcon(faTree); +addIcon(faUserLock); Vue.component('font-awesome-icon', FontAwesomeIcon); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 3b007a32853d8..701cb1388fb82 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -34,7 +34,9 @@ import { RouteConfigSingleView } from 'vue-router/types/router'; import { VIEWS } from './constants'; import { useSettingsStore } from './stores/settings'; import { useTemplatesStore } from './stores/templates'; +import { useSSOStore } from './stores/sso'; import SettingsUsageAndPlanVue from './views/SettingsUsageAndPlan.vue'; +import SettingsSso from './views/SettingsSso.vue'; import SignoutView from '@/views/SignoutView.vue'; Vue.use(Router); @@ -63,612 +65,647 @@ function getTemplatesRedirect() { return false; } -const router = new Router({ - mode: 'history', - base: import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/', - scrollBehavior(to, from, savedPosition) { - // saved position == null means the page is NOT visited from history (back button) - if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) { - // for templates view, reset scroll position in this case - to.meta.setScrollPosition(0); - } - }, - routes: [ - { - path: '/', - name: VIEWS.HOMEPAGE, - meta: { - getRedirect() { - return { name: VIEWS.WORKFLOWS }; - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, +export const routes = [ + { + path: '/', + name: VIEWS.HOMEPAGE, + meta: { + getRedirect() { + return { name: VIEWS.WORKFLOWS }; + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/collections/:id', - name: VIEWS.COLLECTION, - components: { - default: TemplatesCollectionView, - sidebar: MainSidebar, - }, - meta: { - templatesEnabled: true, - telemetry: { - getProperties(route: Route) { - const templatesStore = useTemplatesStore(); - return { - collection_id: route.params.id, - wf_template_repo_session_id: templatesStore.currentSessionId, - }; - }, + }, + { + path: '/collections/:id', + name: VIEWS.COLLECTION, + components: { + default: TemplatesCollectionView, + sidebar: MainSidebar, + }, + meta: { + templatesEnabled: true, + telemetry: { + getProperties(route: Route) { + const templatesStore = useTemplatesStore(); + return { + collection_id: route.params.id, + wf_template_repo_session_id: templatesStore.currentSessionId, + }; }, - getRedirect: getTemplatesRedirect, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + getRedirect: getTemplatesRedirect, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/templates/:id', - name: VIEWS.TEMPLATE, - components: { - default: TemplatesWorkflowView, - sidebar: MainSidebar, - }, - meta: { - templatesEnabled: true, - getRedirect: getTemplatesRedirect, - telemetry: { - getProperties(route: Route) { - const templatesStore = useTemplatesStore(); - return { - template_id: route.params.id, - wf_template_repo_session_id: templatesStore.currentSessionId, - }; - }, + }, + { + path: '/templates/:id', + name: VIEWS.TEMPLATE, + components: { + default: TemplatesWorkflowView, + sidebar: MainSidebar, + }, + meta: { + templatesEnabled: true, + getRedirect: getTemplatesRedirect, + telemetry: { + getProperties(route: Route) { + const templatesStore = useTemplatesStore(); + return { + template_id: route.params.id, + wf_template_repo_session_id: templatesStore.currentSessionId, + }; }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/templates/', - name: VIEWS.TEMPLATES, - components: { - default: TemplatesSearchView, - sidebar: MainSidebar, - }, - meta: { - templatesEnabled: true, - getRedirect: getTemplatesRedirect, - // Templates view remembers it's scroll position on back - scrollOffset: 0, - telemetry: { - getProperties(route: Route) { - const templatesStore = useTemplatesStore(); - return { - wf_template_repo_session_id: templatesStore.currentSessionId, - }; - }, - }, - setScrollPosition(pos: number) { - this.scrollOffset = pos; - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: '/templates/', + name: VIEWS.TEMPLATES, + components: { + default: TemplatesSearchView, + sidebar: MainSidebar, + }, + meta: { + templatesEnabled: true, + getRedirect: getTemplatesRedirect, + // Templates view remembers it's scroll position on back + scrollOffset: 0, + telemetry: { + getProperties(route: Route) { + const templatesStore = useTemplatesStore(); + return { + wf_template_repo_session_id: templatesStore.currentSessionId, + }; }, }, - }, - { - path: '/credentials', - name: VIEWS.CREDENTIALS, - components: { - default: CredentialsView, - sidebar: MainSidebar, + setScrollPosition(pos: number) { + this.scrollOffset = pos; }, - meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/executions', - name: VIEWS.EXECUTIONS, - components: { - default: ExecutionsView, - sidebar: MainSidebar, - }, - meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: '/credentials', + name: VIEWS.CREDENTIALS, + components: { + default: CredentialsView, + sidebar: MainSidebar, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/workflow', - redirect: '/workflow/new', + }, + { + path: '/executions', + name: VIEWS.EXECUTIONS, + components: { + default: ExecutionsView, + sidebar: MainSidebar, }, - { - path: '/workflows', - name: VIEWS.WORKFLOWS, - components: { - default: WorkflowsView, - sidebar: MainSidebar, - }, - meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/workflow/new', - name: VIEWS.NEW_WORKFLOW, - components: { - default: NodeView, - header: MainHeader, - sidebar: MainSidebar, - }, - meta: { - nodeView: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: '/workflow', + redirect: '/workflow/new', + }, + { + path: '/workflows', + name: VIEWS.WORKFLOWS, + components: { + default: WorkflowsView, + sidebar: MainSidebar, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/workflow/:name', - name: VIEWS.WORKFLOW, - components: { - default: NodeView, - header: MainHeader, - sidebar: MainSidebar, - }, - meta: { - nodeView: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: '/workflow/new', + name: VIEWS.NEW_WORKFLOW, + components: { + default: NodeView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + nodeView: true, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/workflow/:name/executions', - name: VIEWS.WORKFLOW_EXECUTIONS, - components: { - default: WorkflowExecutionsList, - header: MainHeader, - sidebar: MainSidebar, + }, + { + path: '/workflow/:name', + name: VIEWS.WORKFLOW, + components: { + default: NodeView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + nodeView: true, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, }, - meta: { - keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + }, + { + path: '/workflow/:name/executions', + name: VIEWS.WORKFLOW_EXECUTIONS, + components: { + default: WorkflowExecutionsList, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, - children: [ - { - path: '', - name: VIEWS.EXECUTION_HOME, - components: { - executionPreview: ExecutionsLandingPage, - }, - meta: { - keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + children: [ + { + path: '', + name: VIEWS.EXECUTION_HOME, + components: { + executionPreview: ExecutionsLandingPage, + }, + meta: { + keepWorkflowAlive: true, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: ':executionId', - name: VIEWS.EXECUTION_PREVIEW, - components: { - executionPreview: ExecutionPreview, - }, - meta: { - keepWorkflowAlive: true, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: ':executionId', + name: VIEWS.EXECUTION_PREVIEW, + components: { + executionPreview: ExecutionPreview, + }, + meta: { + keepWorkflowAlive: true, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - ], - }, - { - path: '/workflows/demo', - name: VIEWS.DEMO, - components: { - default: NodeView, }, - meta: { - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + ], + }, + { + path: '/workflows/demo', + name: VIEWS.DEMO, + components: { + default: NodeView, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/workflows/templates/:id', - name: VIEWS.TEMPLATE_IMPORT, - components: { - default: NodeView, - header: MainHeader, - sidebar: MainSidebar, - }, - meta: { - templatesEnabled: true, - getRedirect: getTemplatesRedirect, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + }, + { + path: '/workflows/templates/:id', + name: VIEWS.TEMPLATE_IMPORT, + components: { + default: NodeView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + templatesEnabled: true, + getRedirect: getTemplatesRedirect, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/signin', - name: VIEWS.SIGNIN, - components: { - default: SigninView, + }, + { + path: '/signin', + name: VIEWS.SIGNIN, + components: { + default: SigninView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedOut], }, }, }, - { - path: '/signup', - name: VIEWS.SIGNUP, - components: { - default: SignupView, + }, + { + path: '/signup', + name: VIEWS.SIGNUP, + components: { + default: SignupView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedOut], }, }, }, - { - path: '/signout', - name: VIEWS.SIGNOUT, - components: { - default: SignoutView, + }, + { + path: '/signout', + name: VIEWS.SIGNOUT, + components: { + default: SignoutView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - { - path: '/setup', - name: VIEWS.SETUP, - components: { - default: SetupView, + }, + { + path: '/setup', + name: VIEWS.SETUP, + components: { + default: SetupView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', + permissions: { + allow: { + role: [ROLE.Default], }, - permissions: { - allow: { - role: [ROLE.Default], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return settingsStore.isUserManagementEnabled === false; - }, + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isUserManagementEnabled === false; }, }, }, }, - { - path: '/forgot-password', - name: VIEWS.FORGOT_PASSWORD, - components: { - default: ForgotMyPasswordView, + }, + { + path: '/forgot-password', + name: VIEWS.FORGOT_PASSWORD, + components: { + default: ForgotMyPasswordView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedOut], }, }, }, - { - path: '/change-password', - name: VIEWS.CHANGE_PASSWORD, - components: { - default: ChangePasswordView, + }, + { + path: '/change-password', + name: VIEWS.CHANGE_PASSWORD, + components: { + default: ChangePasswordView, + }, + meta: { + telemetry: { + pageCategory: 'auth', }, - meta: { - telemetry: { - pageCategory: 'auth', - }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedOut], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedOut], }, }, }, - { - path: '/settings', - component: SettingsView, - props: true, - children: [ - { - path: 'usage', - name: VIEWS.USAGE, - components: { - settingsView: SettingsUsageAndPlanVue, + }, + { + path: '/settings', + component: SettingsView, + props: true, + children: [ + { + path: 'usage', + name: VIEWS.USAGE, + components: { + settingsView: SettingsUsageAndPlanVue, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: 'usage', + }; + }, }, - meta: { - telemetry: { - pageCategory: 'settings', - getProperties(route: Route) { - return { - feature: 'usage', - }; - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return ( - settingsStore.settings.hideUsagePage === true || - settingsStore.settings.deployment?.type === 'cloud' - ); - }, + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); + return ( + settingsStore.settings.hideUsagePage === true || + settingsStore.settings.deployment?.type === 'cloud' + ); }, }, }, }, - { - path: 'personal', - name: VIEWS.PERSONAL_SETTINGS, - components: { - settingsView: SettingsPersonalView, + }, + { + path: 'personal', + name: VIEWS.PERSONAL_SETTINGS, + components: { + settingsView: SettingsPersonalView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: 'personal', + }; + }, }, - meta: { - telemetry: { - pageCategory: 'settings', - getProperties(route: Route) { - return { - feature: 'personal', - }; - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - role: [ROLE.Default], - }, + deny: { + role: [ROLE.Default], }, }, }, - { - path: 'users', - name: VIEWS.USERS_SETTINGS, - components: { - settingsView: SettingsUsersView, + }, + { + path: 'users', + name: VIEWS.USERS_SETTINGS, + components: { + settingsView: SettingsUsersView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: 'users', + }; + }, }, - meta: { - telemetry: { - pageCategory: 'settings', - getProperties(route: Route) { - return { - feature: 'users', - }; - }, + permissions: { + allow: { + role: [ROLE.Default, ROLE.Owner], }, - permissions: { - allow: { - role: [ROLE.Default, ROLE.Owner], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); - return ( - settingsStore.isUserManagementEnabled === false && - !(settingsStore.isCloudDeployment || settingsStore.isDesktopDeployment) - ); - }, + return ( + settingsStore.isUserManagementEnabled === false && + !(settingsStore.isCloudDeployment || settingsStore.isDesktopDeployment) + ); }, }, }, }, - { - path: 'api', - name: VIEWS.API_SETTINGS, - components: { - settingsView: SettingsApiView, + }, + { + path: 'api', + name: VIEWS.API_SETTINGS, + components: { + settingsView: SettingsApiView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: 'api', + }; + }, }, - meta: { - telemetry: { - pageCategory: 'settings', - getProperties(route: Route) { - return { - feature: 'api', - }; - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return settingsStore.isPublicApiEnabled === false; - }, + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isPublicApiEnabled === false; }, }, }, }, - { - path: 'log-streaming', - name: VIEWS.LOG_STREAMING_SETTINGS, - components: { - settingsView: SettingsLogStreamingView, + }, + { + path: 'sso', + name: VIEWS.SSO_SETTINGS, + components: { + settingsView: SettingsSso, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: 'sso', + }; + }, }, - meta: { - telemetry: { - pageCategory: 'settings', + permissions: { + allow: { + role: [ROLE.Owner], }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], - role: [ROLE.Owner], - }, - deny: { - role: [ROLE.Default], + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); + const ssoStore = useSSOStore(); + return ( + !ssoStore.isEnterpriseSamlEnabled || + settingsStore.isCloudDeployment || + settingsStore.isDesktopDeployment + ); }, }, }, }, - { - path: 'community-nodes', - name: VIEWS.COMMUNITY_NODES, - components: { - settingsView: SettingsCommunityNodesView, + }, + { + path: 'log-streaming', + name: VIEWS.LOG_STREAMING_SETTINGS, + components: { + settingsView: SettingsLogStreamingView, + }, + meta: { + telemetry: { + pageCategory: 'settings', }, - meta: { - telemetry: { - pageCategory: 'settings', + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + role: [ROLE.Owner], }, - permissions: { - allow: { - role: [ROLE.Default, ROLE.Owner], - }, - deny: { - shouldDeny: () => { - const settingsStore = useSettingsStore(); - return settingsStore.isCommunityNodesFeatureEnabled === false; - }, - }, + deny: { + role: [ROLE.Default], }, }, }, - { - path: 'coming-soon/:featureId', - name: VIEWS.FAKE_DOOR, - components: { - settingsView: SettingsFakeDoorView, + }, + { + path: 'community-nodes', + name: VIEWS.COMMUNITY_NODES, + components: { + settingsView: SettingsCommunityNodesView, + }, + meta: { + telemetry: { + pageCategory: 'settings', }, - meta: { - telemetry: { - pageCategory: 'settings', - getProperties(route: Route) { - return { - feature: route.params['featureId'], - }; - }, + permissions: { + allow: { + role: [ROLE.Default, ROLE.Owner], }, - permissions: { - allow: { - loginStatus: [LOGIN_STATUS.LoggedIn], + deny: { + shouldDeny: () => { + const settingsStore = useSettingsStore(); + return settingsStore.isCommunityNodesFeatureEnabled === false; }, }, }, }, - { - path: 'ldap', - name: VIEWS.LDAP_SETTINGS, - components: { - settingsView: SettingsLdapView, + }, + { + path: 'coming-soon/:featureId', + name: VIEWS.FAKE_DOOR, + components: { + settingsView: SettingsFakeDoorView, + }, + meta: { + telemetry: { + pageCategory: 'settings', + getProperties(route: Route) { + return { + feature: route.params['featureId'], + }; + }, }, - meta: { - permissions: { - allow: { - role: [ROLE.Owner], - }, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], }, }, }, - ], - }, - { - path: '*', - name: VIEWS.NOT_FOUND, - component: ErrorView, - props: { - messageKey: 'error.pageNotFound', - errorCode: 404, - redirectTextKey: 'error.goBack', - redirectPage: VIEWS.HOMEPAGE, - }, - meta: { - nodeView: true, - telemetry: { - disabled: true, - }, - permissions: { - allow: { - // TODO: Once custom permissions are merged, this needs to be updated with index validation - loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut], + }, + { + path: 'ldap', + name: VIEWS.LDAP_SETTINGS, + components: { + settingsView: SettingsLdapView, + }, + meta: { + permissions: { + allow: { + role: [ROLE.Owner], + }, }, }, }, + ], + }, + { + path: '*', + name: VIEWS.NOT_FOUND, + component: ErrorView, + props: { + messageKey: 'error.pageNotFound', + errorCode: 404, + redirectTextKey: 'error.goBack', + redirectPage: VIEWS.HOMEPAGE, }, - ] as IRouteConfig[], + meta: { + nodeView: true, + telemetry: { + disabled: true, + }, + permissions: { + allow: { + // TODO: Once custom permissions are merged, this needs to be updated with index validation + loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut], + }, + }, + }, + }, +] as IRouteConfig[]; + +const router = new Router({ + mode: 'history', + base: import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/', + scrollBehavior(to, from, savedPosition) { + // saved position == null means the page is NOT visited from history (back button) + if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) { + // for templates view, reset scroll position in this case + to.meta.setScrollPosition(0); + } + }, + routes, }); export default router; diff --git a/packages/editor-ui/src/stores/sso.ts b/packages/editor-ui/src/stores/sso.ts index 52779c4608c7d..229bf99ec4316 100644 --- a/packages/editor-ui/src/stores/sso.ts +++ b/packages/editor-ui/src/stores/sso.ts @@ -1,9 +1,10 @@ -import { computed, reactive } from 'vue'; +import { computed, reactive, ref } from 'vue'; import { defineStore } from 'pinia'; import { EnterpriseEditionFeature } from '@/constants'; import { useRootStore } from '@/stores/n8nRootStore'; import { useSettingsStore } from '@/stores/settings'; -import { initSSO } from '@/api/sso'; +import * as ssoApi from '@/api/sso'; +import { SamlPreferences } from '@/Interface'; export const useSSOStore = defineStore('sso', () => { const rootStore = useRootStore(); @@ -19,7 +20,22 @@ export const useSSOStore = defineStore('sso', () => { state.loading = loading; }; - const isSamlLoginEnabled = computed(() => settingsStore.isSamlLoginEnabled); + const isSamlLoginEnabled = computed({ + get: () => settingsStore.isSamlLoginEnabled, + set: (value: boolean) => { + settingsStore.setSettings({ + ...settingsStore.settings, + sso: { + ...settingsStore.settings.sso, + saml: { + ...settingsStore.settings.sso.saml, + loginEnabled: value, + }, + }, + }); + toggleLoginEnabled(value); + }, + }); const isEnterpriseSamlEnabled = computed(() => settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Saml), ); @@ -31,12 +47,27 @@ export const useSSOStore = defineStore('sso', () => { isDefaultAuthenticationSaml.value, ); - const getSSORedirectUrl = () => initSSO(rootStore.getRestApiContext); + const getSSORedirectUrl = () => ssoApi.initSSO(rootStore.getRestApiContext); + + const toggleLoginEnabled = (enabled: boolean) => + ssoApi.toggleSamlConfig(rootStore.getRestApiContext, { loginEnabled: enabled }); + + const getSamlMetadata = () => ssoApi.getSamlMetadata(rootStore.getRestApiContext); + const getSamlConfig = () => ssoApi.getSamlConfig(rootStore.getRestApiContext); + const saveSamlConfig = (config: SamlPreferences) => + ssoApi.saveSamlConfig(rootStore.getRestApiContext, config); + const testSamlConfig = () => ssoApi.testSamlConfig(rootStore.getRestApiContext); return { isLoading, setLoading, + isSamlLoginEnabled, + isEnterpriseSamlEnabled, showSsoLoginButton, getSSORedirectUrl, + getSamlMetadata, + getSamlConfig, + saveSamlConfig, + testSamlConfig, }; }); diff --git a/packages/editor-ui/src/utils/__tests__/userUtils.test.ts b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts new file mode 100644 index 0000000000000..e2fb83d3a8557 --- /dev/null +++ b/packages/editor-ui/src/utils/__tests__/userUtils.test.ts @@ -0,0 +1,124 @@ +import { beforeAll } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { isAuthorized } from '@/utils'; +import { useSettingsStore } from '@/stores/settings'; +import { useSSOStore } from '@/stores/sso'; +import { IN8nUISettings, IUser, UserManagementAuthenticationMethod } from '@/Interface'; +import { routes } from '@/router'; +import { VIEWS } from '@/constants'; + +const DEFAULT_SETTINGS: IN8nUISettings = { + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + advancedExecutionFilters: false, + sharing: false, + ldap: false, + saml: false, + logStreaming: false, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'production' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'sse', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { enabled: false }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }, + versionCli: '', + versionNotifications: { + enabled: false, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, + deployment: { + type: 'default', + }, +}; + +const DEFAULT_USER: IUser = { + id: '1', + isPending: false, + isDefaultUser: true, + isOwner: false, + isPendingUser: false, + globalRole: { + name: 'default', + id: '1', + createdAt: new Date(), + }, +}; + +describe('userUtils', () => { + let settingsStore: ReturnType; + let ssoStore: ReturnType; + + describe('isAuthorized', () => { + beforeAll(() => { + setActivePinia(createPinia()); + settingsStore = useSettingsStore(); + ssoStore = useSSOStore(); + }); + + it('should check SSO settings route permissions', () => { + const ssoSettingsPermissions = routes + .find((route) => route.path.startsWith('/settings')) + ?.children?.find((route) => route.name === VIEWS.SSO_SETTINGS)?.meta?.permissions; + + const user: IUser = { + ...DEFAULT_USER, + isDefaultUser: false, + isOwner: true, + globalRole: { + ...DEFAULT_USER.globalRole, + id: '1', + name: 'owner', + createdAt: new Date(), + }, + }; + + settingsStore.setSettings({ + ...DEFAULT_SETTINGS, + enterprise: { ...DEFAULT_SETTINGS.enterprise, saml: true }, + }); + + expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true); + }); + }); +}); diff --git a/packages/editor-ui/src/utils/typeHelpers.ts b/packages/editor-ui/src/utils/typeHelpers.ts new file mode 100644 index 0000000000000..ce7e0b412773a --- /dev/null +++ b/packages/editor-ui/src/utils/typeHelpers.ts @@ -0,0 +1 @@ +export type PartialBy = Omit & Partial>; diff --git a/packages/editor-ui/src/views/SettingsSso.vue b/packages/editor-ui/src/views/SettingsSso.vue new file mode 100644 index 0000000000000..280e38cae925f --- /dev/null +++ b/packages/editor-ui/src/views/SettingsSso.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index 71afbfe4ebe34..87d24e2b6580c 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -47,13 +47,6 @@ @copyInviteLink="onCopyInviteLink" /> - @@ -61,8 +54,7 @@ import { EnterpriseEditionFeature, INVITE_USER_MODAL_KEY, VIEWS } from '@/constants'; import PageAlert from '../components/PageAlert.vue'; -import FeatureComingSoon from '@/components/FeatureComingSoon.vue'; -import { IFakeDoor, IUser, IUserListAction } from '@/Interface'; +import { IUser, IUserListAction } from '@/Interface'; import mixins from 'vue-typed-mixins'; import { showMessage } from '@/mixins/showMessage'; import { copyPaste } from '@/mixins/copyPaste'; @@ -77,7 +69,6 @@ export default mixins(showMessage, copyPaste).extend({ name: 'SettingsUsersView', components: { PageAlert, - FeatureComingSoon, }, async mounted() { if (!this.usersStore.showUMSetupWarning) { @@ -107,9 +98,6 @@ export default mixins(showMessage, copyPaste).extend({ }, ]; }, - fakeDoorFeatures(): IFakeDoor[] { - return this.uiStore.getFakeDoorByLocation('settings/users'); - }, }, methods: { redirectToSetup() { diff --git a/packages/editor-ui/vite.config.ts b/packages/editor-ui/vite.config.ts index 763ac18008f25..92054f873c6db 100644 --- a/packages/editor-ui/vite.config.ts +++ b/packages/editor-ui/vite.config.ts @@ -132,5 +132,15 @@ export default mergeConfig( }, }, }, + resolve: { + alias: [ + // https://github.com/vitest-dev/vitest/discussions/1806 + { + find: /^monaco-editor$/, + replacement: + __dirname + "/node_modules/monaco-editor/esm/vs/editor/editor.api", + }, + ], + }, }), );