From bb83673c23576d3b486aa42836356ae321016ce6 Mon Sep 17 00:00:00 2001 From: Mathieu Suen Date: Thu, 16 Mar 2023 11:59:25 +0100 Subject: [PATCH] SONAR-18662 Improve Admin experience for SAML/SCIM configuration --- .../js/api/mocks/AuthenticationServiceMock.ts | 71 +- server/sonar-web/src/main/js/api/settings.ts | 14 + .../main/js/apps/quality-profiles/styles.css | 3 - .../components/DefinitionDescription.tsx | 56 + .../components/DefinitionRenderer.tsx | 33 +- .../apps/settings/components/PageHeader.tsx | 2 +- .../components/SubCategoryDefinitionsList.tsx | 6 +- .../authentication/Authentication.tsx | 94 +- .../authentication/SamlAuthentication.tsx | 609 ++-- .../authentication/SamlConfigurationForm.tsx | 150 + .../authentication/SamlFormField.tsx | 23 +- .../authentication/SamlSecuredField.tsx | 25 +- .../authentication/SamlToggleField.tsx | 6 +- .../__tests__/Authentication-it.tsx | 248 +- .../hook/useLoadSamlSettings.ts | 160 ++ .../src/main/js/apps/settings/styles.css | 46 + .../main/js/components/controls/RadioCard.tsx | 3 + .../main/js/helpers/mocks/definitions-list.ts | 2529 +++++++++++++++++ .../sonar-web/src/main/js/types/features.ts | 1 + .../sonar-web/src/main/js/types/settings.ts | 2 + .../resources/org/sonar/l10n/core.properties | 27 +- 21 files changed, 3507 insertions(+), 601 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts create mode 100644 server/sonar-web/src/main/js/helpers/mocks/definitions-list.ts diff --git a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts index 8aba451dd6e..f4986f2d69b 100644 --- a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts @@ -21,32 +21,79 @@ 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'; +import { + activateScim, + deactivateScim, + fetchIsScimEnabled, + getValues, + resetSettingValue, + setSettingValue, +} from '../settings'; export default class AuthenticationServiceMock { settingValues: SettingValue[]; + scimStatus: boolean; 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' }), + { + key: 'sonar.auth.saml.signature.enabled', + value: 'false', + inherited: true, + }, + { + key: 'sonar.auth.saml.enabled', + value: 'false', + inherited: true, + }, + { + key: 'sonar.auth.saml.applicationId', + value: 'sonarqube', + inherited: true, + }, + { + key: 'sonar.auth.saml.providerName', + value: 'SAML', + inherited: true, + }, ]; 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); + this.scimStatus = false; + jest.mocked(getValues).mockImplementation(this.handleGetValues); + jest.mocked(setSettingValue).mockImplementation(this.handleSetValue); + jest.mocked(resetSettingValue).mockImplementation(this.handleResetValue); + jest.mocked(activateScim).mockImplementation(this.handleActivateScim); + jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim); + + jest.mocked(fetchIsScimEnabled).mockImplementation(this.handleFetchIsScimEnabled); } - getValuesHandler = (data: { keys: string; component?: string } & BranchParameters) => { - if (data.keys) { + handleActivateScim = () => { + this.scimStatus = true; + return Promise.resolve(); + }; + + handleDeactivateScim = () => { + this.scimStatus = false; + return Promise.resolve(); + }; + + handleFetchIsScimEnabled = () => { + return Promise.resolve(this.scimStatus); + }; + + handleGetValues = ( + data: { keys: string[]; component?: string } & BranchParameters + ): Promise => { + if (data.keys.length > 1) { return Promise.resolve(this.settingValues.filter((set) => data.keys.includes(set.key))); } return Promise.resolve(this.settingValues); }; - setValueHandler = (definition: SettingDefinition, value: string) => { + handleSetValue = (definition: SettingDefinition, value: string | boolean) => { if (value === 'error') { const res = new Response('', { status: 400, @@ -57,12 +104,14 @@ export default class AuthenticationServiceMock { } const updatedSettingValue = this.settingValues.find((set) => set.key === definition.key); if (updatedSettingValue) { - updatedSettingValue.value = value; + updatedSettingValue.value = String(value); + } else { + this.settingValues.push({ key: definition.key, value: String(value), inherited: false }); } return Promise.resolve(); }; - resetValueHandler = (data: { keys: string; component?: string } & BranchParameters) => { + handleResetValue = (data: { keys: string; component?: string } & BranchParameters) => { if (data.keys) { this.settingValues.forEach((set) => { if (data.keys.includes(set.key)) { diff --git a/server/sonar-web/src/main/js/api/settings.ts b/server/sonar-web/src/main/js/api/settings.ts index 1d81865ddbe..d741cb0bc00 100644 --- a/server/sonar-web/src/main/js/api/settings.ts +++ b/server/sonar-web/src/main/js/api/settings.ts @@ -116,3 +116,17 @@ export function encryptValue(value: string): Promise<{ encryptedValue: string }> export function getLoginMessage(): Promise<{ message: string }> { return getJSON('/api/settings/login_message').catch(throwGlobalError); } + +export function fetchIsScimEnabled(): Promise { + return getJSON('/api/scim_management/status') + .then((r) => r.enabled) + .catch(throwGlobalError); +} + +export function activateScim(): Promise { + return post('/api/scim_management/enable').catch(throwGlobalError); +} + +export function deactivateScim(): Promise { + return post('/api/scim_management/disable').catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/styles.css b/server/sonar-web/src/main/js/apps/quality-profiles/styles.css index 4fb6b68c3e7..a62977828a6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/styles.css +++ b/server/sonar-web/src/main/js/apps/quality-profiles/styles.css @@ -21,9 +21,6 @@ padding-top: 7px; } -.quality-profiles-table-name { -} - .quality-profiles-table-inheritance { width: 280px; } diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx new file mode 100644 index 00000000000..57e3e16812c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx @@ -0,0 +1,56 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { Tooltip } from 'design-system/lib'; +import * as React from 'react'; +import { translateWithParameters } from '../../../helpers/l10n'; +import { sanitizeStringRestricted } from '../../../helpers/sanitize'; +import { ExtendedSettingDefinition } from '../../../types/settings'; +import { getPropertyDescription, getPropertyName } from '../utils'; + +interface Props { + definition: ExtendedSettingDefinition; +} + +export default function DefinitionDescription({ definition }: Props) { + const propertyName = getPropertyName(definition); + const description = getPropertyDescription(definition); + + return ( +
+

+ {propertyName} +

+ + {description && ( +
+ )} + + +
+ {translateWithParameters('settings.key_x', definition.key)} +
+
+
+ ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx index 62d5573ce42..47249b579be 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx @@ -19,20 +19,13 @@ */ import classNames from 'classnames'; import * as React from 'react'; -import Tooltip from '../../../components/controls/Tooltip'; import AlertErrorIcon from '../../../components/icons/AlertErrorIcon'; import AlertSuccessIcon from '../../../components/icons/AlertSuccessIcon'; import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { sanitizeStringRestricted } from '../../../helpers/sanitize'; import { ExtendedSettingDefinition, SettingValue } from '../../../types/settings'; -import { - combineDefinitionAndSettingValue, - getPropertyDescription, - getPropertyName, - getSettingValue, - isDefaultOrInherited, -} from '../utils'; +import { combineDefinitionAndSettingValue, getSettingValue, isDefaultOrInherited } from '../utils'; import DefinitionActions from './DefinitionActions'; +import DefinitionDescription from './DefinitionDescription'; import Input from './inputs/Input'; export interface DefinitionRendererProps { @@ -56,12 +49,10 @@ export default function DefinitionRenderer(props: DefinitionRendererProps) { const { changedValue, loading, validationMessage, settingValue, success, definition, isEditing } = props; - const propertyName = getPropertyName(definition); const hasError = validationMessage != null; const hasValueChanged = changedValue != null; const effectiveValue = hasValueChanged ? changedValue : getSettingValue(definition, settingValue); const isDefault = isDefaultOrInherited(settingValue); - const description = getPropertyDescription(definition); const settingDefinitionAndValue = combineDefinitionAndSettingValue(definition, settingValue); @@ -72,25 +63,7 @@ export default function DefinitionRenderer(props: DefinitionRendererProps) { })} data-key={definition.key} > -
-

- {propertyName} -

- - {description && ( -
- )} - - -
- {translateWithParameters('settings.key_x', definition.key)} -
-
-
+
diff --git a/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx index a2a54aba945..7d42d29c7ad 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx @@ -42,7 +42,7 @@ export default function PageHeader({ component, definitions }: PageHeaderProps)
-

{title}

+

{title}

{description}
(
  • {displaySubCategoryTitle && ( -

    {subCategory.name} -

    + )} {subCategory.description != null && (
    -

    {translate('settings.authentication.title')}

    +

    {translate('settings.authentication.title')}

    {props.hasFeature(Feature.LoginMessage) && ( @@ -147,48 +147,54 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { {/* Adding a key to force re-rendering of the tab container, so that it resets the scroll position */} {({ top }) => ( -
    -
    - - - {translate('settings.authentication.help.link')} - - ), - }} - /> - - {currentTab === SAML && ( - def.subCategory === SAML)} - /> - )} + <> + {tabs.map((tab) => ( +
    +
    + {tab.key === SAML && } - {currentTab !== SAML && ( - - )} -
    -
    + {tab.key !== SAML && ( + <> + + + {translate('settings.authentication.help.link')} + + ), + }} + /> + + + + )} +
    +
    + ))} + )}
    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 index 379f179f1ef..9186bbada03 100644 --- 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 @@ -17,382 +17,297 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import classNames from 'classnames'; -import { keyBy } from 'lodash'; +import { isEmpty } from 'lodash'; import React from 'react'; -import { getValues, resetSettingValue, setSettingValue } from '../../../../api/settings'; -import { SubmitButton } from '../../../../components/controls/buttons'; -import Tooltip from '../../../../components/controls/Tooltip'; -import { Location, withRouter } from '../../../../components/hoc/withRouter'; -import AlertSuccessIcon from '../../../../components/icons/AlertSuccessIcon'; -import AlertWarnIcon from '../../../../components/icons/AlertWarnIcon'; -import DetachIcon from '../../../../components/icons/DetachIcon'; -import DeferredSpinner from '../../../../components/ui/DeferredSpinner'; -import { translate, translateWithParameters } from '../../../../helpers/l10n'; -import { isSuccessStatus, parseError } from '../../../../helpers/request'; +import { FormattedMessage } from 'react-intl'; +import { + activateScim, + deactivateScim, + resetSettingValue, + setSettingValue, +} from '../../../../api/settings'; +import DocLink from '../../../../components/common/DocLink'; +import Link from '../../../../components/common/Link'; +import { Button, ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; +import ConfirmModal from '../../../../components/controls/ConfirmModal'; +import RadioCard from '../../../../components/controls/RadioCard'; +import CheckIcon from '../../../../components/icons/CheckIcon'; +import DeleteIcon from '../../../../components/icons/DeleteIcon'; +import EditIcon from '../../../../components/icons/EditIcon'; +import { Alert } from '../../../../components/ui/Alert'; +import { translate } from '../../../../helpers/l10n'; import { getBaseUrl } from '../../../../helpers/system'; -import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings'; -import SamlFormField from './SamlFormField'; -import SamlToggleField from './SamlToggleField'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { getPropertyName } from '../../utils'; +import DefinitionDescription from '../DefinitionDescription'; +import useSamlConfiguration, { SAML_ENABLED_FIELD } from './hook/useLoadSamlSettings'; +import SamlConfigurationForm from './SamlConfigurationForm'; interface SamlAuthenticationProps { definitions: ExtendedSettingDefinition[]; - location: Location; } -interface SamlAuthenticationState { - settingValue: Pick[]; - submitting: boolean; - dirtyFields: string[]; - securedFieldsSubmitted: string[]; - error: { [key: string]: string }; - success?: boolean; -} +export const SAML = 'saml'; const CONFIG_TEST_PATH = '/saml/validation_init'; -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', - 'sonar.scim.enabled', -]; - -class SamlAuthentication extends React.PureComponent< - SamlAuthenticationProps, - SamlAuthenticationState -> { - formFieldRef: React.RefObject = React.createRef(); - - 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); - // Added setTimeout to make sure the component gets updated before scrolling - setTimeout(() => { - if (location.hash) { - this.scrollToSearchedField(); - } - }); - this.loadSettingValues(keys); - } - - componentDidUpdate(prevProps: SamlAuthenticationProps) { - const { location } = this.props; - if (prevProps.location.hash !== location.hash) { - this.scrollToSearchedField(); - } - } - - scrollToSearchedField = () => { - if (this.formFieldRef.current) { - this.formFieldRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest', - }); - } +export default function SamlAuthentication(props: SamlAuthenticationProps) { + const { definitions } = props; + const [showEditModal, setShowEditModal] = React.useState(false); + const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = React.useState(false); + const { + hasScim, + scimStatus, + loading, + samlEnabled, + name, + groupValue, + url, + hasConfiguration, + values, + setNewValue, + canBeSave, + hasScimConfigChange, + newScimStatus, + setNewScimStatus, + setNewGroupSetting, + onReload, + } = useSamlConfiguration(definitions); + + const handleDeleteConfiguration = async () => { + await resetSettingValue({ keys: Object.keys(values).join(',') }); + await onReload(); }; - 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, - }); + const handleCreateConfiguration = () => { + setShowEditModal(true); }; - 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; + const handleCancelConfiguration = () => { + setShowEditModal(false); }; - onSaveConfig = async () => { - const { settingValue, dirtyFields } = this.state; - const { definitions } = this.props; - - if (dirtyFields.length === 0) { - return; - } - - this.setState({ submitting: true, error: {}, success: false }); - const promises: Promise[] = []; - - dirtyFields.forEach((field) => { - const definition = definitions.find((def) => def.key === field); - const value = settingValue.find((def) => def.key === field)?.value; - if (definition && value !== undefined) { - const apiCall = - value.length > 0 - ? setSettingValue(definition, value) - : resetSettingValue({ keys: definition.key }); - - promises.push(apiCall); - } - }); - - await Promise.all(promises.map((p) => p.catch((e) => e))).then((data) => { - const dataWithError = data - .map((data, index) => ({ data, index })) - .filter((d) => d.data !== undefined && !isSuccessStatus(d.data.status)); - if (dataWithError.length > 0) { - dataWithError.forEach(async (d) => { - const validationMessage = await parseError(d.data as Response); - const { error } = this.state; - this.setState({ - error: { ...error, ...{ [dirtyFields[d.index]]: validationMessage } }, - }); - }); - } - this.setState({ success: dirtyFields.length !== dataWithError.length }); - }); - await this.loadSettingValues(dirtyFields); - this.setState({ submitting: false, dirtyFields: [] }); + const handleToggleEnable = async () => { + const value = values[SAML_ENABLED_FIELD]; + await setSettingValue(value.definition, !samlEnabled); + await onReload(); }; - allowEnabling = () => { - const { settingValue } = this.state; - const enabledFlagSettingValue = settingValue.find((set) => set.key === SAML_ENABLED_FIELD); - - if (enabledFlagSettingValue && enabledFlagSettingValue.value === 'true') { - return true; - } - - return this.getEmptyRequiredFields().length === 0; - }; - - onEnableFlagChange = (value: boolean) => { - const { settingValue, dirtyFields } = this.state; - - const updatedSettingValue = settingValue?.map((set) => { - if (set.key === SAML_ENABLED_FIELD) { - set.value = String(value); + const handleSaveGroup = async () => { + if (groupValue.newValue !== undefined) { + if (isEmpty(groupValue.newValue)) { + await resetSettingValue({ keys: groupValue.definition.key }); + } else { + await setSettingValue(groupValue.definition, groupValue.newValue); } - return set; - }); - - this.setState( - { - settingValue: updatedSettingValue, - dirtyFields: [...dirtyFields, SAML_ENABLED_FIELD], - }, - () => { - this.onSaveConfig(); - } - ); - }; - - getTestButtonTooltipContent = (formIsIncomplete: boolean, hasDirtyFields: boolean) => { - if (hasDirtyFields) { - return translate('settings.authentication.saml.form.test.help.dirty'); - } - - if (formIsIncomplete) { - return translate('settings.authentication.saml.form.test.help.incomplete'); + await onReload(); } - - return null; }; - getEmptyRequiredFields = () => { - const { settingValue, securedFieldsSubmitted } = this.state; - const { definitions } = this.props; - - const updatedRequiredFields: string[] = []; - - 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)) { - const settingDef = definitions.find((def) => def.key === setting.key); - - if (settingDef && settingDef.name) { - updatedRequiredFields.push(settingDef.name); - } - } + const handleConfirmChangeProvisioning = async () => { + if (newScimStatus) { + await activateScim(); + } else { + await deactivateScim(); + await handleSaveGroup(); } - return updatedRequiredFields; + await onReload(); }; - render() { - const { definitions } = this.props; - const { submitting, settingValue, securedFieldsSubmitted, error, dirtyFields, success } = - this.state; - const enabledFlagDefinition = definitions.find((def) => def.key === SAML_ENABLED_FIELD); + return ( +
    +
    +

    {translate('settings.authentication.saml.configuration')}

    - const formIsIncomplete = !this.allowEnabling(); - const preventTestingConfig = this.getEmptyRequiredFields().length > 0 || dirtyFields.length > 0; - - 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} - /> + {!hasConfiguration && ( +
    + +
    + )} +
    + {!hasConfiguration && ( +
    + {translate('settings.authentication.saml.form.not_configured')} +
    + )} + + {hasConfiguration && ( + <> +
    +
    +
    {name}
    +

    {url}

    +

    + {samlEnabled ? ( + + + {translate('settings.authentication.saml.form.enabled')} + + ) : ( + translate('settings.authentication.saml.form.not_enabled') + )} +

    +
    - ); - })} -
    - {enabledFlagDefinition && ( - -
    - - set.key === enabledFlagDefinition.key)} - toggleDisabled={formIsIncomplete} - onChange={this.onEnableFlagChange} - /> -
    -
    - )} -
    - {success && ( -
    - 0 - ? translateWithParameters( - 'settings.authentication.saml.form.save_warn', - Object.keys(error).length - ) - : null - } - > - {Object.keys(error).length > 0 ? ( - - - {translate('settings.authentication.saml.form.save_partial')} - - ) : ( - - - {translate('settings.authentication.saml.form.save_success')} - - )} - {} - -
    - )} - - {translate('settings.authentication.saml.form.save')} - - - - 0)} - > - + - {translate('settings.authentication.saml.form.test')} - - + + + +
    -
    -
    - ); - } + {hasScim && ( +
    +
    { + e.preventDefault(); + if (newScimStatus !== scimStatus) { + setShowConfirmProvisioningModal(true); + } else { + handleSaveGroup(); + } + }} + > +
    + + {samlEnabled ? ( +
    + setNewScimStatus(true)} + > +

    + {translate( + 'settings.authentication.saml.form.provisioning_with_scim.sub' + )} +

    +

    + + {translate('documentation')} + + ), + }} + /> +

    +
    + setNewScimStatus(false)} + > +

    + {translate('settings.authentication.saml.form.provisioning_at_login.sub')} +

    + {groupValue && ( +
    + +
    + setNewGroupSetting(e.currentTarget.value)} + type="text" + value={String(groupValue.newValue ?? groupValue.value ?? '')} + aria-label={getPropertyName(groupValue.definition)} + /> +
    +
    + )} +
    +
    + ) : ( + + {translate('settings.authentication.saml.enable_first')} + + )} +
    + {samlEnabled && ( + <> + {translate('save')} + { + setNewScimStatus(undefined); + setNewGroupSetting(); + }} + disabled={!hasScimConfigChange} + > + {translate('cancel')} + + + )} + {showConfirmProvisioningModal && ( + handleConfirmChangeProvisioning()} + header={translate( + 'settings.authentication.saml.confirm', + newScimStatus ? 'scim' : 'jit' + )} + onClose={() => setShowConfirmProvisioningModal(false)} + isDestructive={!newScimStatus} + confirmButtonText={translate('yes')} + > + {translate( + 'settings.authentication.saml.confirm', + newScimStatus ? 'scim' : 'jit', + 'description' + )} + + )} +
    +
    + )} + + )} + {showEditModal && ( + + )} +
    + ); } - -export default withRouter(SamlAuthentication); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx new file mode 100644 index 00000000000..028429f6031 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx @@ -0,0 +1,150 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { isEmpty, keyBy } from 'lodash'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { setSettingValue } from '../../../../api/settings'; +import DocLink from '../../../../components/common/DocLink'; +import { ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; +import Modal from '../../../../components/controls/Modal'; +import { Alert } from '../../../../components/ui/Alert'; +import DeferredSpinner from '../../../../components/ui/DeferredSpinner'; +import { translate } from '../../../../helpers/l10n'; +import { Dict } from '../../../../types/types'; +import { + SamlSettingValue, + SAML_ENABLED_FIELD, + SAML_GROUP_NAME, + SAML_SCIM_DEPRECATED, +} from './hook/useLoadSamlSettings'; +import SamlFormField from './SamlFormField'; + +interface Props { + create: boolean; + loading: boolean; + values: Dict; + setNewValue: (key: string, value: string | boolean) => void; + canBeSave: boolean; + onClose: () => void; + onReload: () => Promise; +} + +interface ErrorValue { + key: string; + message: string; +} + +export const SAML = 'saml'; + +const SAML_EXCLUDED_FIELD = [SAML_ENABLED_FIELD, SAML_GROUP_NAME, SAML_SCIM_DEPRECATED]; + +export default function SamlConfigurationForm(props: Props) { + const { create, loading, values, setNewValue, canBeSave } = props; + const [errors, setErrors] = React.useState>({}); + + const headerLabel = translate('settings.authentication.saml.form', create ? 'create' : 'edit'); + + const handleSubmit = async (event: React.SyntheticEvent) => { + event.preventDefault(); + + if (canBeSave) { + const r = await Promise.all( + Object.values(values) + .filter((v) => v.newValue !== undefined) + .map(async ({ key, newValue, definition }) => { + try { + await setSettingValue(definition, newValue); + return { key, success: true }; + } catch (error) { + return { key, success: false }; + } + }) + ); + const errors = r + .filter(({ success }) => !success) + .map(({ key }) => ({ key, message: translate('default_save_field_error_message') })); + setErrors(keyBy(errors, 'key')); + if (isEmpty(errors)) { + await props.onReload(); + props.onClose(); + } + } else { + const errors = Object.values(values) + .filter((v) => v.newValue === undefined && v.value === undefined && v.mandatory) + .map((v) => ({ key: v.key, message: translate('field_required') })); + setErrors(keyBy(errors, 'key')); + } + }; + + return ( + +
    +
    +

    {headerLabel}

    +
    +
    + + + + {translate('settings.authentication.help.link')} + + ), + }} + /> + + {Object.values(values).map((val) => { + if (SAML_EXCLUDED_FIELD.includes(val.key)) { + return null; + } + return ( +
    + +
    + ); + })} +
    +
    + +
    + + {translate('settings.almintegration.form.save')} + + + {translate('cancel')} +
    +
    +
    + ); +} 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 index 639c37c69ef..d86afef207d 100644 --- 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 @@ -22,24 +22,24 @@ import ValidationInput, { ValidationInputErrorPlacement, } from '../../../../components/controls/ValidationInput'; import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker'; -import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings'; +import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; import SamlSecuredField from './SamlSecuredField'; import SamlToggleField from './SamlToggleField'; interface SamlToggleFieldProps { - settingValue?: SettingValue; + settingValue?: string | boolean; definition: ExtendedSettingDefinition; mandatory?: boolean; onFieldChange: (key: string, value: string | boolean) => void; - showSecuredTextArea?: boolean; - error: { [key: string]: string }; + isNotSet: boolean; + error?: string; } export default function SamlFormField(props: SamlToggleFieldProps) { - const { mandatory = false, definition, settingValue, showSecuredTextArea = true, error } = props; + const { mandatory = false, definition, settingValue, isNotSet, error } = props; return ( -
    +