From e65d9c91e86d486dba2d1e8e43997f163060a502 Mon Sep 17 00:00:00 2001 From: Revanshu Paliwal Date: Fri, 9 Sep 2022 11:44:36 +0200 Subject: [PATCH] SONAR-17295 Improve SAML configuration page user experience --- .../js/api/mocks/AuthenticationServiceMock.ts | 76 +++++ .../authentication/Authentication.tsx | 23 +- .../authentication/SamlAuthentication.tsx | 266 ++++++++++++++++++ .../authentication/SamlFormField.tsx | 93 ++++++ .../authentication/SamlSecuredField.tsx | 67 +++++ .../authentication/SamlToggleField.tsx | 42 +++ .../__tests__/Authentication-test.tsx | 97 ++++++- .../src/main/js/apps/settings/styles.css | 12 + .../resources/org/sonar/l10n/core.properties | 1 + 9 files changed, 667 insertions(+), 10 deletions(-) create mode 100644 server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx diff --git a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts new file mode 100644 index 00000000000..c0b5b8a8bcc --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts @@ -0,0 +1,76 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { cloneDeep } from 'lodash'; +import { mockSettingValue } from '../../helpers/mocks/settings'; +import { BranchParameters } from '../../types/branch-like'; +import { SettingDefinition, SettingValue } from '../../types/settings'; +import { getValues, resetSettingValue, setSettingValue } from '../settings'; + +export default class AuthenticationServiceMock { + settingValues: SettingValue[]; + defaulSettingValues: SettingValue[] = [ + mockSettingValue({ key: 'test1', value: '' }), + mockSettingValue({ key: 'test2', value: 'test2' }), + mockSettingValue({ key: 'sonar.auth.saml.certificate.secured' }), + mockSettingValue({ key: 'sonar.auth.saml.enabled', value: 'false' }) + ]; + + constructor() { + this.settingValues = cloneDeep(this.defaulSettingValues); + (getValues as jest.Mock).mockImplementation(this.getValuesHandler); + (setSettingValue as jest.Mock).mockImplementation(this.setValueHandler); + (resetSettingValue as jest.Mock).mockImplementation(this.resetValueHandler); + } + + getValuesHandler = (data: { keys: string; component?: string } & BranchParameters) => { + if (data.keys) { + return Promise.resolve( + this.settingValues.filter(set => data.keys.split(',').includes(set.key)) + ); + } + return Promise.resolve(this.settingValues); + }; + + setValueHandler = (definition: SettingDefinition, value: string) => { + const updatedSettingValue = this.settingValues.find(set => set.key === definition.key); + if (updatedSettingValue) { + updatedSettingValue.value = value; + } + return Promise.resolve(); + }; + + resetValueHandler = (data: { keys: string; component?: string } & BranchParameters) => { + if (data.keys) { + return Promise.resolve( + this.settingValues.map(set => { + if (data.keys.includes(set.key)) { + set.value = ''; + } + return set; + }) + ); + } + return Promise.resolve(this.settingValues); + }; + + resetValues = () => { + this.settingValues = cloneDeep(this.defaulSettingValues); + }; +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx index 8a7cc2d47f5..d8ce34a0b2d 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx @@ -31,6 +31,7 @@ import { AlmKeys } from '../../../../types/alm-settings'; import { ExtendedSettingDefinition } from '../../../../types/settings'; import { AUTHENTICATION_CATEGORY } from '../../constants'; import CategoryDefinitionsList from '../CategoryDefinitionsList'; +import SamlAuthentication from './SamlAuthentication'; interface Props { definitions: ExtendedSettingDefinition[]; @@ -134,7 +135,7 @@ export default function Authentication(props: Props) { role="tabpanel" aria-labelledby={getTabId(currentTab)} id={getTabPanelId(currentTab)}> -
+
- + {currentTab === SAML && ( + def.subCategory === SAML)} + /> + )} + + {currentTab !== SAML && ( + + )}
)} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx new file mode 100644 index 00000000000..0ee14c319b4 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx @@ -0,0 +1,266 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { keyBy } from 'lodash'; +import React from 'react'; +import { getValues, resetSettingValue, setSettingValue } from '../../../../api/settings'; +import { SubmitButton } from '../../../../components/controls/buttons'; +import DeferredSpinner from '../../../../components/ui/DeferredSpinner'; +import { translate } from '../../../../helpers/l10n'; +import { parseError } from '../../../../helpers/request'; +import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings'; +import SamlFormField from './SamlFormField'; +import SamlToggleField from './SamlToggleField'; + +interface SamlAuthenticationProps { + definitions: ExtendedSettingDefinition[]; +} + +interface SamlAuthenticationState { + settingValue: Pick[]; + submitting: boolean; + dirtyFields: string[]; + securedFieldsSubmitted: string[]; + error: { [key: string]: string }; +} + +const SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled'; + +const OPTIONAL_FIELDS = [ + 'sonar.auth.saml.sp.certificate.secured', + 'sonar.auth.saml.sp.privateKey.secured', + 'sonar.auth.saml.signature.enabled', + 'sonar.auth.saml.user.email', + 'sonar.auth.saml.group.name' +]; + +class SamlAuthentication extends React.PureComponent< + SamlAuthenticationProps, + SamlAuthenticationState +> { + constructor(props: SamlAuthenticationProps) { + super(props); + const settingValue = props.definitions.map(def => { + return { + key: def.key + }; + }); + + this.state = { + settingValue, + submitting: false, + dirtyFields: [], + securedFieldsSubmitted: [], + error: {} + }; + } + + componentDidMount() { + const { definitions } = this.props; + const keys = definitions.map(definition => definition.key).join(','); + this.loadSettingValues(keys); + } + + onFieldChange = (id: string, value: string | boolean) => { + const { settingValue, dirtyFields } = this.state; + const updatedSettingValue = settingValue?.map(set => { + if (set.key === id) { + set.value = String(value); + } + return set; + }); + + if (!dirtyFields.includes(id)) { + const updatedDirtyFields = [...dirtyFields, id]; + this.setState({ + dirtyFields: updatedDirtyFields + }); + } + + this.setState({ + settingValue: updatedSettingValue + }); + }; + + async loadSettingValues(keys: string) { + const { settingValue, securedFieldsSubmitted } = this.state; + const values = await getValues({ + keys + }); + const valuesByDefinitionKey = keyBy(values, 'key'); + const updatedSecuredFieldsSubmitted: string[] = [...securedFieldsSubmitted]; + const updateSettingValue = settingValue?.map(set => { + if (valuesByDefinitionKey[set.key]) { + set.value = + valuesByDefinitionKey[set.key].value ?? valuesByDefinitionKey[set.key].parentValue; + } + + if ( + this.isSecuredField(set.key) && + valuesByDefinitionKey[set.key] && + !securedFieldsSubmitted.includes(set.key) + ) { + updatedSecuredFieldsSubmitted.push(set.key); + } + + return set; + }); + + this.setState({ + settingValue: updateSettingValue, + securedFieldsSubmitted: updatedSecuredFieldsSubmitted + }); + } + + isSecuredField = (key: string) => { + const { definitions } = this.props; + const fieldDefinition = definitions.find(def => def.key === key); + if (fieldDefinition && fieldDefinition.type === SettingType.PASSWORD) { + return true; + } + return false; + }; + + onSaveConfig = async () => { + const { settingValue, dirtyFields } = this.state; + const { definitions } = this.props; + + if (dirtyFields.length === 0) { + return; + } + + this.setState({ submitting: true, error: {} }); + const promises: Promise[] = []; + + settingValue?.forEach(set => { + const definition = definitions.find(def => def.key === set.key); + if (definition && set.value !== undefined && dirtyFields.includes(set.key)) { + const apiCall = + set.value.length > 0 + ? setSettingValue(definition, set.value) + : resetSettingValue({ keys: definition.key }); + const promise = apiCall.catch(async e => { + const { error } = this.state; + const validationMessage = await parseError(e as Response); + this.setState({ + submitting: false, + dirtyFields: [], + error: { ...error, ...{ [set.key]: validationMessage } } + }); + }); + promises.push(promise); + } + }); + await Promise.all(promises); + await this.loadSettingValues(dirtyFields.join(',')); + + this.setState({ submitting: false, dirtyFields: [] }); + }; + + allowEnabling = () => { + const { settingValue, securedFieldsSubmitted } = this.state; + const enabledFlagSettingValue = settingValue.find(set => set.key === SAML_ENABLED_FIELD); + if (enabledFlagSettingValue && enabledFlagSettingValue.value === 'true') { + return true; + } + + for (const setting of settingValue) { + const isMandatory = !OPTIONAL_FIELDS.includes(setting.key); + const isSecured = this.isSecuredField(setting.key); + const isSecuredAndNotSubmitted = isSecured && !securedFieldsSubmitted.includes(setting.key); + const isNotSecuredAndNotSubmitted = + !isSecured && (setting.value === '' || setting.value === undefined); + if (isMandatory && (isSecuredAndNotSubmitted || isNotSecuredAndNotSubmitted)) { + return false; + } + } + return true; + }; + + onEnableFlagChange = (value: boolean) => { + const { settingValue, dirtyFields } = this.state; + + const updatedSettingValue = settingValue?.map(set => { + if (set.key === SAML_ENABLED_FIELD) { + set.value = String(value); + } + return set; + }); + + this.setState( + { + settingValue: updatedSettingValue, + dirtyFields: [...dirtyFields, SAML_ENABLED_FIELD] + }, + () => { + this.onSaveConfig(); + } + ); + }; + + render() { + const { definitions } = this.props; + const { submitting, settingValue, securedFieldsSubmitted, error, dirtyFields } = this.state; + const enabledFlagDefinition = definitions.find(def => def.key === SAML_ENABLED_FIELD); + + return ( +
+ {definitions.map(def => { + if (def.key === SAML_ENABLED_FIELD) { + return null; + } + return ( + set.key === def.key)} + definition={def} + mandatory={!OPTIONAL_FIELDS.includes(def.key)} + onFieldChange={this.onFieldChange} + showSecuredTextArea={ + !securedFieldsSubmitted.includes(def.key) || dirtyFields.includes(def.key) + } + error={error} + key={def.key} + /> + ); + })} +
+ {enabledFlagDefinition && ( +
+ + set.key === enabledFlagDefinition.key)} + toggleDisabled={!this.allowEnabling()} + onChange={this.onEnableFlagChange} + /> +
+ )} +
+ + {translate('settings.authentication.saml.form.save')} + + +
+
+
+ ); + } +} + +export default SamlAuthentication; diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx new file mode 100644 index 00000000000..c73285875cd --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx @@ -0,0 +1,93 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import React from 'react'; +import ValidationInput, { + ValidationInputErrorPlacement +} from '../../../../components/controls/ValidationInput'; +import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker'; +import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings'; +import SamlSecuredField from './SamlSecuredField'; +import SamlToggleField from './SamlToggleField'; + +interface SamlToggleFieldProps { + settingValue?: SettingValue; + definition: ExtendedSettingDefinition; + mandatory?: boolean; + onFieldChange: (key: string, value: string | boolean) => void; + showSecuredTextArea?: boolean; + error: { [key: string]: string }; +} + +const SAML_SIGNATURE_FIELD = 'sonar.auth.saml.signature.enabled'; + +export default function SamlFormField(props: SamlToggleFieldProps) { + const { mandatory = false, definition, settingValue, showSecuredTextArea = true, error } = props; + + return ( +
+
+ + {mandatory && } + {definition.description && ( +
{definition.description}
+ )} +
+
+ {definition.type === SettingType.PASSWORD && ( + + )} + {definition.type === SettingType.BOOLEAN && ( + props.onFieldChange(SAML_SIGNATURE_FIELD, val)} + /> + )} + {definition.type === undefined && ( + + props.onFieldChange(definition.key, e.currentTarget.value)} + size={50} + type="text" + value={settingValue?.value ?? ''} + aria-label={definition.key} + /> + + )} +
+
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx new file mode 100644 index 00000000000..4cc6353b001 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx @@ -0,0 +1,67 @@ +/* + * SonarQube + * Copyright (C) 2009-2022 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import React, { useEffect } from 'react'; +import { ButtonLink } from '../../../../components/controls/buttons'; +import { translate } from '../../../../helpers/l10n'; +import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings'; + +interface SamlToggleFieldProps { + onFieldChange: (key: string, value: string) => void; + settingValue?: SettingValue; + definition: ExtendedSettingDefinition; + optional?: boolean; + showTextArea: boolean; +} + +export default function SamlSecuredField(props: SamlToggleFieldProps) { + const { settingValue, definition, optional = true, showTextArea } = props; + const [showField, setShowField] = React.useState(showTextArea); + + useEffect(() => { + setShowField(showTextArea); + }, [showTextArea]); + + return ( + <> + {showField && ( +