diff options
Diffstat (limited to 'server/sonar-web/src/main/js/apps/settings')
13 files changed, 874 insertions, 584 deletions
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 ( + <div className="settings-definition-left"> + <h4 className="settings-definition-name" title={propertyName}> + {propertyName} + </h4> + + {description && ( + <div + className="markdown small spacer-top" + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ __html: sanitizeStringRestricted(description) }} + /> + )} + + <Tooltip overlay={translateWithParameters('settings.key_x', definition.key)}> + <div className="settings-definition-key note little-spacer-top"> + {translateWithParameters('settings.key_x', definition.key)} + </div> + </Tooltip> + </div> + ); +} 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} > - <div className="settings-definition-left"> - <h3 className="settings-definition-name" title={propertyName}> - {propertyName} - </h3> - - {description && ( - <div - className="markdown small spacer-top" - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ __html: sanitizeStringRestricted(description) }} - /> - )} - - <Tooltip overlay={translateWithParameters('settings.key_x', definition.key)}> - <div className="settings-definition-key note little-spacer-top"> - {translateWithParameters('settings.key_x', definition.key)} - </div> - </Tooltip> - </div> + <DefinitionDescription definition={definition} /> <div className="settings-definition-right"> <div className="settings-definition-state"> 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) <header className="top-bar-outer"> <div className="top-bar"> <div className="top-bar-inner bordered-bottom big-padded-top padded-bottom"> - <h1 className="page-title">{title}</h1> + <h2 className="page-title">{title}</h2> <div className="page-description spacer-top">{description}</div> <SettingsSearch className="big-spacer-top" diff --git a/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx b/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx index 697791cc20d..68872889c96 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx @@ -82,13 +82,13 @@ class SubCategoryDefinitionsList extends React.PureComponent<SubCategoryDefiniti {filteredSubCategories.map((subCategory) => ( <li key={subCategory.key}> {displaySubCategoryTitle && ( - <h2 - className="settings-sub-category-name" + <h3 + className="settings-sub-category-name h2" data-key={subCategory.key} ref={this.scrollToSubCategoryOrDefinition} > {subCategory.name} - </h2> + </h3> )} {subCategory.description != null && ( <div 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 6f6cde5ea53..361c2495e63 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 @@ -17,6 +17,7 @@ * 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 * as React from 'react'; import { FormattedMessage } from 'react-intl'; import { useSearchParams } from 'react-router-dom'; @@ -36,7 +37,7 @@ import { Feature } from '../../../../types/features'; import { ExtendedSettingDefinition } from '../../../../types/settings'; import { AUTHENTICATION_CATEGORY } from '../../constants'; import CategoryDefinitionsList from '../CategoryDefinitionsList'; -import SamlAuthentication from './SamlAuthentication'; +import SamlAuthentication, { SAML } from './SamlAuthentication'; interface Props { definitions: ExtendedSettingDefinition[]; @@ -45,7 +46,6 @@ interface Props { // We substract the footer height with padding (80) and the main layout padding (20) const HEIGHT_ADJUSTMENT = 100; -const SAML = 'saml'; export type AuthenticationTabs = | typeof SAML | AlmKeys.GitHub @@ -114,7 +114,7 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { return ( <> <header className="page-header"> - <h1 className="page-title">{translate('settings.authentication.title')}</h1> + <h3 className="page-title h2">{translate('settings.authentication.title')}</h3> </header> {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 */} <ScreenPositionHelper> {({ top }) => ( - <div - style={{ - maxHeight: `calc(100vh - ${top + HEIGHT_ADJUSTMENT}px)`, - }} - className="bordered overflow-y-auto tabbed-definitions" - key={currentTab} - role="tabpanel" - aria-labelledby={getTabId(currentTab)} - id={getTabPanelId(currentTab)} - > - <div className="big-padded-top big-padded-left big-padded-right"> - <Alert variant="info"> - <FormattedMessage - id="settings.authentication.help" - defaultMessage={translate('settings.authentication.help')} - values={{ - link: ( - <DocLink - to={`/instance-administration/authentication/${DOCUMENTATION_LINK_SUFFIXES[currentTab]}/`} - > - {translate('settings.authentication.help.link')} - </DocLink> - ), - }} - /> - </Alert> - {currentTab === SAML && ( - <SamlAuthentication - definitions={definitions.filter((def) => def.subCategory === SAML)} - /> - )} + <> + {tabs.map((tab) => ( + <div + style={{ + maxHeight: tab.key !== SAML ? `calc(100vh - ${top + HEIGHT_ADJUSTMENT}px)` : '', + }} + className={classNames('bordered overflow-y-auto tabbed-definitions', { + hidden: currentTab !== tab.key, + })} + key={tab.key} + role="tabpanel" + aria-labelledby={getTabId(tab.key)} + id={getTabPanelId(tab.key)} + > + <div className="big-padded-top big-padded-left big-padded-right"> + {tab.key === SAML && <SamlAuthentication definitions={definitions} />} - {currentTab !== SAML && ( - <CategoryDefinitionsList - category={AUTHENTICATION_CATEGORY} - definitions={definitions} - subCategory={currentTab} - displaySubCategoryTitle={false} - /> - )} - </div> - </div> + {tab.key !== SAML && ( + <> + <Alert variant="info"> + <FormattedMessage + id="settings.authentication.help" + defaultMessage={translate('settings.authentication.help')} + values={{ + link: ( + <DocLink + to={`/instance-administration/authentication/${ + DOCUMENTATION_LINK_SUFFIXES[tab.key as AuthenticationTabs] + }/`} + > + {translate('settings.authentication.help.link')} + </DocLink> + ), + }} + /> + </Alert> + <CategoryDefinitionsList + category={AUTHENTICATION_CATEGORY} + definitions={definitions} + subCategory={tab.key} + displaySubCategoryTitle={false} + /> + </> + )} + </div> + </div> + ))} + </> )} </ScreenPositionHelper> </> 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<SettingValue, 'key' | 'value'>[]; - 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<HTMLDivElement> = 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<void>[] = []; - - 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 ( + <div className="saml-configuration"> + <div className="spacer-bottom display-flex-space-between display-flex-center"> + <h4>{translate('settings.authentication.saml.configuration')}</h4> - const formIsIncomplete = !this.allowEnabling(); - const preventTestingConfig = this.getEmptyRequiredFields().length > 0 || dirtyFields.length > 0; - - return ( - <div> - {definitions.map((def) => { - if (def.key === SAML_ENABLED_FIELD) { - return null; - } - return ( - <div - key={def.key} - ref={this.props.location.hash.substring(1) === def.key ? this.formFieldRef : null} - > - <SamlFormField - settingValue={settingValue?.find((set) => 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 && ( + <div> + <Button onClick={handleCreateConfiguration}> + {translate('settings.authentication.form.create')} + </Button> + </div> + )} + </div> + {!hasConfiguration && ( + <div className="big-padded text-center huge-spacer-bottom saml-no-config"> + {translate('settings.authentication.saml.form.not_configured')} + </div> + )} + + {hasConfiguration && ( + <> + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + <div> + <h5>{name}</h5> + <p>{url}</p> + <p className="big-spacer-top big-spacer-bottom"> + {samlEnabled ? ( + <span className="saml-enabled spacer-left"> + <CheckIcon className="spacer-right" /> + {translate('settings.authentication.saml.form.enabled')} + </span> + ) : ( + translate('settings.authentication.saml.form.not_enabled') + )} + </p> + <Button className="spacer-top" disabled={scimStatus} onClick={handleToggleEnable}> + {samlEnabled + ? translate('settings.authentication.saml.form.disable') + : translate('settings.authentication.saml.form.enable')} + </Button> </div> - ); - })} - <div className="fixed-footer padded"> - {enabledFlagDefinition && ( - <Tooltip - overlay={ - this.allowEnabling() - ? null - : translateWithParameters( - 'settings.authentication.saml.tooltip.required_fields', - this.getEmptyRequiredFields().join(', ') - ) - } - > - <div className="display-inline-flex-center"> - <label className="h3 spacer-right">{enabledFlagDefinition.name}</label> - <SamlToggleField - definition={enabledFlagDefinition} - settingValue={settingValue?.find((set) => set.key === enabledFlagDefinition.key)} - toggleDisabled={formIsIncomplete} - onChange={this.onEnableFlagChange} - /> - </div> - </Tooltip> - )} - <div className="display-inline-flex-center"> - {success && ( - <div className="spacer-right"> - <Tooltip - overlay={ - Object.keys(error).length > 0 - ? translateWithParameters( - 'settings.authentication.saml.form.save_warn', - Object.keys(error).length - ) - : null - } - > - {Object.keys(error).length > 0 ? ( - <span> - <AlertWarnIcon className="spacer-right" /> - {translate('settings.authentication.saml.form.save_partial')} - </span> - ) : ( - <span> - <AlertSuccessIcon className="spacer-right" /> - {translate('settings.authentication.saml.form.save_success')} - </span> - )} - {} - </Tooltip> - </div> - )} - <SubmitButton className="button-primary spacer-right" onClick={this.onSaveConfig}> - {translate('settings.authentication.saml.form.save')} - <DeferredSpinner className="spacer-left" loading={submitting} /> - </SubmitButton> - - <Tooltip - overlay={this.getTestButtonTooltipContent(formIsIncomplete, dirtyFields.length > 0)} - > - <a - className={classNames('button', { - disabled: preventTestingConfig, - })} - href={preventTestingConfig ? undefined : `${getBaseUrl()}${CONFIG_TEST_PATH}`} + <div> + <Link + className="button spacer-right" target="_blank" - rel="noopener noreferrer" + to={`${getBaseUrl()}${CONFIG_TEST_PATH}`} > - <DetachIcon className="spacer-right" /> {translate('settings.authentication.saml.form.test')} - </a> - </Tooltip> + </Link> + <Button className="spacer-right" onClick={handleCreateConfiguration}> + <EditIcon /> + {translate('settings.authentication.form.edit')} + </Button> + <Button + className="button-red" + disabled={samlEnabled} + onClick={handleDeleteConfiguration} + > + <DeleteIcon /> + {translate('settings.authentication.form.delete')} + </Button> + </div> </div> - </div> - </div> - ); - } + {hasScim && ( + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + <form + onSubmit={(e) => { + e.preventDefault(); + if (newScimStatus !== scimStatus) { + setShowConfirmProvisioningModal(true); + } else { + handleSaveGroup(); + } + }} + > + <fieldset className="display-flex-column big-spacer-bottom"> + <label className="h5"> + {translate('settings.authentication.saml.form.provisioning')} + </label> + {samlEnabled ? ( + <div className="display-flex-row spacer-top"> + <RadioCard + label={translate( + 'settings.authentication.saml.form.provisioning_with_scim' + )} + title={translate( + 'settings.authentication.saml.form.provisioning_with_scim' + )} + selected={newScimStatus ?? scimStatus} + onClick={() => setNewScimStatus(true)} + > + <p className="spacer-bottom"> + {translate( + 'settings.authentication.saml.form.provisioning_with_scim.sub' + )} + </p> + <p> + <FormattedMessage + id="settings.authentication.saml.form.provisioning_with_scim.description" + defaultMessage={translate( + 'settings.authentication.saml.form.provisioning_with_scim.description' + )} + values={{ + doc: ( + <DocLink to="/instance-administration/authentication/saml/scim/overview"> + {translate('documentation')} + </DocLink> + ), + }} + /> + </p> + </RadioCard> + <RadioCard + label={translate('settings.authentication.saml.form.provisioning_at_login')} + title={translate('settings.authentication.saml.form.provisioning_at_login')} + selected={!(newScimStatus ?? scimStatus)} + onClick={() => setNewScimStatus(false)} + > + <p> + {translate('settings.authentication.saml.form.provisioning_at_login.sub')} + </p> + {groupValue && ( + <div className="settings-definition"> + <DefinitionDescription definition={groupValue.definition} /> + <div className="settings-definition-right"> + <input + id={groupValue.definition.key} + maxLength={4000} + name={groupValue.definition.key} + onChange={(e) => setNewGroupSetting(e.currentTarget.value)} + type="text" + value={String(groupValue.newValue ?? groupValue.value ?? '')} + aria-label={getPropertyName(groupValue.definition)} + /> + </div> + </div> + )} + </RadioCard> + </div> + ) : ( + <Alert className="big-spacer-top" variant="info"> + {translate('settings.authentication.saml.enable_first')} + </Alert> + )} + </fieldset> + {samlEnabled && ( + <> + <SubmitButton disabled={!hasScimConfigChange}>{translate('save')}</SubmitButton> + <ResetButtonLink + className="spacer-left" + onClick={() => { + setNewScimStatus(undefined); + setNewGroupSetting(); + }} + disabled={!hasScimConfigChange} + > + {translate('cancel')} + </ResetButtonLink> + </> + )} + {showConfirmProvisioningModal && ( + <ConfirmModal + onConfirm={() => 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' + )} + </ConfirmModal> + )} + </form> + </div> + )} + </> + )} + {showEditModal && ( + <SamlConfigurationForm + loading={loading} + values={values} + setNewValue={setNewValue} + canBeSave={canBeSave} + onClose={handleCancelConfiguration} + create={!hasConfiguration} + onReload={onReload} + /> + )} + </div> + ); } - -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<SamlSettingValue>; + setNewValue: (key: string, value: string | boolean) => void; + canBeSave: boolean; + onClose: () => void; + onReload: () => Promise<void>; +} + +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<Dict<ErrorValue>>({}); + + const headerLabel = translate('settings.authentication.saml.form', create ? 'create' : 'edit'); + + const handleSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => { + 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 ( + <Modal contentLabel={headerLabel} shouldCloseOnOverlayClick={false} size="medium"> + <form className="views-form create-saml-form" onSubmit={handleSubmit}> + <div className="modal-head"> + <h2>{headerLabel}</h2> + </div> + <div className="modal-body modal-container"> + <DeferredSpinner + loading={loading} + ariaLabel={translate('settings.authentication.saml.form.loading')} + > + <Alert variant="info"> + <FormattedMessage + id="settings.authentication.help" + defaultMessage={translate('settings.authentication.help')} + values={{ + link: ( + <DocLink to="/instance-administration/authentication/saml/overview/"> + {translate('settings.authentication.help.link')} + </DocLink> + ), + }} + /> + </Alert> + {Object.values(values).map((val) => { + if (SAML_EXCLUDED_FIELD.includes(val.key)) { + return null; + } + return ( + <div key={val.key}> + <SamlFormField + settingValue={values[val.key]?.newValue ?? values[val.key]?.value} + definition={val.definition} + mandatory={val.mandatory} + onFieldChange={setNewValue} + isNotSet={val.isNotSet} + error={errors[val.key]?.message} + /> + </div> + ); + })} + </DeferredSpinner> + </div> + + <div className="modal-foot"> + <SubmitButton disabled={!canBeSave}> + {translate('settings.almintegration.form.save')} + <DeferredSpinner className="spacer-left" loading={loading} /> + </SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </div> + </form> + </Modal> + ); +} 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 ( - <div className="settings-definition" key={definition.key}> + <div className="settings-definition"> <div className="settings-definition-left"> <label className="h3" htmlFor={definition.key}> {definition.name} @@ -53,9 +53,9 @@ export default function SamlFormField(props: SamlToggleFieldProps) { {definition.type === SettingType.PASSWORD && ( <SamlSecuredField definition={definition} - settingValue={settingValue} + settingValue={String(settingValue ?? '')} onFieldChange={props.onFieldChange} - showTextArea={showSecuredTextArea} + isNotSet={isNotSet} /> )} {definition.type === SettingType.BOOLEAN && ( @@ -68,10 +68,10 @@ export default function SamlFormField(props: SamlToggleFieldProps) { )} {definition.type === undefined && ( <ValidationInput - error={error[definition.key]} + error={error} errorPlacement={ValidationInputErrorPlacement.Bottom} isValid={false} - isInvalid={Boolean(error[definition.key])} + isInvalid={Boolean(error)} > <input className="width-100" @@ -80,8 +80,7 @@ export default function SamlFormField(props: SamlToggleFieldProps) { name={definition.key} onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)} type="text" - value={settingValue?.value ?? ''} - aria-label={definition.key} + value={String(settingValue ?? '')} /> </ValidationInput> )} 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 index e8947ee85c6..a7177a2a114 100644 --- 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 @@ -20,27 +20,30 @@ import React, { useEffect } from 'react'; import { ButtonLink } from '../../../../components/controls/buttons'; import { translate } from '../../../../helpers/l10n'; -import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { isSecuredDefinition } from '../../utils'; interface SamlToggleFieldProps { onFieldChange: (key: string, value: string) => void; - settingValue?: SettingValue; + settingValue?: string; definition: ExtendedSettingDefinition; optional?: boolean; - showTextArea: boolean; + isNotSet: boolean; } export default function SamlSecuredField(props: SamlToggleFieldProps) { - const { settingValue, definition, optional = true, showTextArea } = props; - const [showField, setShowField] = React.useState(showTextArea); + const { settingValue, definition, optional = true, isNotSet } = props; + const [showSecretField, setShowSecretField] = React.useState( + !isNotSet && isSecuredDefinition(definition) + ); useEffect(() => { - setShowField(showTextArea); - }, [showTextArea]); + setShowSecretField(!isNotSet && isSecuredDefinition(definition)); + }, [isNotSet, definition]); return ( <> - {showField && ( + {!showSecretField && ( <textarea className="width-100" id={definition.key} @@ -48,15 +51,15 @@ export default function SamlSecuredField(props: SamlToggleFieldProps) { onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)} required={!optional} rows={5} - value={settingValue?.value ?? ''} + value={settingValue ?? ''} /> )} - {!showField && ( + {showSecretField && ( <div> <p>{translate('settings.almintegration.form.secret.field')}</p> <ButtonLink onClick={() => { - setShowField(true); + setShowSecretField(false); }} > {translate('settings.almintegration.form.secret.update_field')} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx index ba0cd8b1c13..a7c787c2cd0 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx @@ -19,12 +19,12 @@ */ import React from 'react'; import Toggle from '../../../../components/controls/Toggle'; -import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; interface SamlToggleFieldProps { toggleDisabled: boolean; onChange: (value: boolean) => void; - settingValue?: SettingValue; + settingValue?: string | boolean; definition: ExtendedSettingDefinition; } @@ -35,7 +35,7 @@ export default function SamlToggleField(props: SamlToggleFieldProps) { <Toggle name={definition.key} onChange={props.onChange} - value={settingValue?.value ?? ''} + value={settingValue ?? ''} disabled={toggleDisabled} /> ); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx index 463e2b9666d..0e597dc7bb3 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx @@ -17,53 +17,20 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import React from 'react'; import { byRole, byText } from 'testing-library-selector'; import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock'; import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; -import { mockDefinition } from '../../../../../helpers/mocks/settings'; +import { definitions } from '../../../../../helpers/mocks/definitions-list'; import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; import { Feature } from '../../../../../types/features'; -import { ExtendedSettingDefinition, SettingType } from '../../../../../types/settings'; import Authentication from '../Authentication'; jest.mock('../../../../../api/settings'); -const mockDefinitionFields = [ - mockDefinition({ - key: 'test1', - category: 'authentication', - subCategory: 'saml', - name: 'test1', - description: 'desc1', - }), - mockDefinition({ - key: 'test2', - category: 'authentication', - subCategory: 'saml', - name: 'test2', - description: 'desc2', - }), - mockDefinition({ - key: 'sonar.auth.saml.certificate.secured', - category: 'authentication', - subCategory: 'saml', - name: 'Certificate', - description: 'Secured certificate', - type: SettingType.PASSWORD, - }), - mockDefinition({ - key: 'sonar.auth.saml.enabled', - category: 'authentication', - subCategory: 'saml', - name: 'Enabled', - description: 'To enable the flag', - type: SettingType.BOOLEAN, - }), -]; - let handler: AuthenticationServiceMock; beforeEach(() => { @@ -79,11 +46,57 @@ const ui = { testButton: byText('settings.authentication.saml.form.test'), textbox1: byRole('textbox', { name: 'test1' }), textbox2: byRole('textbox', { name: 'test2' }), + saml: { + noSamlConfiguration: byText('settings.authentication.saml.form.not_configured'), + createConfigButton: byRole('button', { name: 'settings.authentication.form.create' }), + providerName: byRole('textbox', { name: 'Provider Name' }), + providerId: byRole('textbox', { name: 'Provider ID' }), + providerCertificate: byRole('textbox', { name: 'Identity provider certificate' }), + loginUrl: byRole('textbox', { name: 'SAML login url' }), + userLoginAttribute: byRole('textbox', { name: 'SAML user login attribute' }), + userNameAttribute: byRole('textbox', { name: 'SAML user name attribute' }), + saveConfigButton: byRole('button', { name: 'settings.almintegration.form.save' }), + confirmProvisioningButton: byRole('button', { name: 'yes' }), + saveScim: byRole('button', { name: 'save' }), + groupAttribute: byRole('textbox', { name: 'property.sonar.auth.saml.group.name.name' }), + enableConfigButton: byRole('button', { name: 'settings.authentication.saml.form.enable' }), + disableConfigButton: byRole('button', { name: 'settings.authentication.saml.form.disable' }), + editConfigButton: byRole('button', { name: 'settings.authentication.form.edit' }), + enableFirstMessage: byText('settings.authentication.saml.enable_first'), + jitProvisioningButton: byRole('radio', { + name: 'settings.authentication.saml.form.provisioning_at_login', + }), + scimProvisioningButton: byRole('radio', { + name: 'settings.authentication.saml.form.provisioning_with_scim', + }), + fillForm: async (user: UserEvent) => { + const { saml } = ui; + await act(async () => { + await user.clear(saml.providerName.get()); + await user.type(saml.providerName.get(), 'Awsome SAML config'); + await user.type(saml.providerId.get(), 'okta-1234'); + await user.type(saml.loginUrl.get(), 'http://test.org'); + await user.type(saml.providerCertificate.get(), '-secret-'); + await user.type(saml.userLoginAttribute.get(), 'login'); + await user.type(saml.userNameAttribute.get(), 'name'); + }); + }, + createConfiguration: async (user: UserEvent) => { + const { saml } = ui; + await act(async () => { + await user.click(await saml.createConfigButton.find()); + }); + await saml.fillForm(user); + await act(async () => { + await user.click(saml.saveConfigButton.get()); + }); + }, + }, }; it('should render tabs and allow navigation', async () => { const user = userEvent.setup(); - renderAuthentication([]); + renderAuthentication(); expect(screen.getAllByRole('tab')).toHaveLength(4); @@ -98,118 +111,87 @@ it('should render tabs and allow navigation', async () => { ); }); +it('should not display the login message feature info box', () => { + renderAuthentication(); + + expect(ui.customMessageInformation.query()).not.toBeInTheDocument(); +}); + +it('should display the login message feature info box', () => { + renderAuthentication([Feature.LoginMessage]); + + expect(ui.customMessageInformation.get()).toBeInTheDocument(); +}); + describe('SAML tab', () => { - it('should allow user to test the configuration', async () => { - const user = userEvent.setup(); + const { saml } = ui; - const definitions = [ - mockDefinition({ - key: 'sonar.auth.saml.certificate.secured', - category: 'authentication', - subCategory: 'saml', - name: 'Certificate', - description: 'Secured certificate', - type: SettingType.PASSWORD, - }), - mockDefinition({ - key: 'sonar.auth.saml.enabled', - category: 'authentication', - subCategory: 'saml', - name: 'Enabled', - description: 'To enable the flag', - type: SettingType.BOOLEAN, - }), - ]; - - renderAuthentication(definitions); - - await user.click(await screen.findByText('settings.almintegration.form.secret.update_field')); - - await user.click(screen.getByRole('textbox', { name: 'Certificate' })); - await user.keyboard('new certificate'); - - expect(ui.testButton.get()).toHaveClass('disabled'); - - await user.click(ui.saveButton.get()); - - expect(ui.testButton.get()).not.toHaveClass('disabled'); + it('should render an empty SAML configuration', async () => { + renderAuthentication(); + expect(await saml.noSamlConfiguration.find()).toBeInTheDocument(); }); - it('should allow user to edit fields and save configuration', async () => { + it('should be able to create a configuration', async () => { const user = userEvent.setup(); - const definitions = mockDefinitionFields; - renderAuthentication(definitions); - - expect(ui.enabledToggle.get()).toHaveAttribute('aria-disabled', 'true'); - // update fields - await user.click(ui.textbox1.get()); - await user.keyboard('new test1'); - - await user.click(ui.textbox2.get()); - await user.keyboard('new test2'); - // check if enable is allowed after updating - expect(ui.enabledToggle.get()).toHaveAttribute('aria-disabled', 'false'); - - // reset value - await user.click(ui.textbox2.get()); - await user.keyboard('{Control>}a{/Control}{Backspace}'); - await user.click(ui.saveButton.get()); - expect(ui.enabledToggle.get()).toHaveAttribute('aria-disabled', 'true'); - - await user.click(ui.textbox2.get()); - await user.keyboard('new test2'); - expect(ui.enabledToggle.get()).toHaveAttribute('aria-disabled', 'false'); - - expect( - screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' }) - ).toBeInTheDocument(); - await user.click( - screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' }) - ); - // check for secure fields - expect(screen.getByRole('textbox', { name: 'Certificate' })).toBeInTheDocument(); - await user.click(screen.getByRole('textbox', { name: 'Certificate' })); - await user.keyboard('new certificate'); - // enable the configuration - await user.click(ui.enabledToggle.get()); - expect(ui.enabledToggle.get()).toBeChecked(); - - await user.click(ui.saveButton.get()); - expect(screen.getByText('settings.authentication.saml.form.save_success')).toBeInTheDocument(); - // check after switching tab that the flag is still enabled - await user.click(screen.getByRole('tab', { name: 'github GitHub' })); - await user.click(screen.getByRole('tab', { name: 'SAML' })); - - expect(ui.enabledToggle.get()).toBeChecked(); + renderAuthentication(); + + await user.click(await saml.createConfigButton.find()); + + expect(saml.saveConfigButton.get()).toBeDisabled(); + await saml.fillForm(user); + expect(saml.saveConfigButton.get()).toBeEnabled(); + + await act(async () => { + await user.click(saml.saveConfigButton.get()); + }); + + expect(await saml.editConfigButton.find()).toBeInTheDocument(); }); - it('should handle and show errors to the user', async () => { + it('should be able to enable/disable configuration', async () => { + const { saml } = ui; const user = userEvent.setup(); - const definitions = mockDefinitionFields; - renderAuthentication(definitions); - - await user.click(ui.textbox1.get()); - await user.keyboard('value'); - await user.click(ui.textbox2.get()); - await user.keyboard('{Control>}a{/Control}error'); - await user.click(ui.saveButton.get()); - expect(screen.getByText('settings.authentication.saml.form.save_partial')).toBeInTheDocument(); - }); + renderAuthentication(); + + await saml.createConfiguration(user); + await user.click(await saml.enableConfigButton.find()); - it('should not display the login message feature info box', () => { - renderAuthentication([]); + expect(await saml.disableConfigButton.find()).toBeInTheDocument(); + await user.click(saml.disableConfigButton.get()); + expect(saml.disableConfigButton.query()).not.toBeInTheDocument(); - expect(ui.customMessageInformation.query()).not.toBeInTheDocument(); + expect(await saml.enableConfigButton.find()).toBeInTheDocument(); }); - it('should display the login message feature info box', () => { - renderAuthentication([], [Feature.LoginMessage]); + it('should be able to choose provisioning', async () => { + const { saml } = ui; + const user = userEvent.setup(); + + renderAuthentication([Feature.Scim]); + + await saml.createConfiguration(user); + + expect(await saml.enableFirstMessage.find()).toBeInTheDocument(); + await user.click(await saml.enableConfigButton.find()); + + expect(await saml.jitProvisioningButton.find()).toBeChecked(); + + await user.type(saml.groupAttribute.get(), 'group'); + expect(saml.saveScim.get()).toBeEnabled(); + await user.click(saml.saveScim.get()); + expect(await saml.saveScim.find()).toBeDisabled(); + + await user.click(saml.scimProvisioningButton.get()); + expect(saml.saveScim.get()).toBeEnabled(); + await user.click(saml.saveScim.get()); + await user.click(saml.confirmProvisioningButton.get()); - expect(ui.customMessageInformation.get()).toBeInTheDocument(); + expect(await saml.scimProvisioningButton.find()).toBeChecked(); + expect(await saml.saveScim.find()).toBeDisabled(); }); }); -function renderAuthentication(definitions: ExtendedSettingDefinition[], features: Feature[] = []) { +function renderAuthentication(features: Feature[] = []) { renderComponent( <AvailableFeaturesContext.Provider value={features}> <Authentication definitions={definitions} /> diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts new file mode 100644 index 00000000000..af29e0ea0c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts @@ -0,0 +1,160 @@ +/* + * 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 { every, isEmpty, keyBy } from 'lodash'; +import React from 'react'; +import { fetchIsScimEnabled, getValues } from '../../../../../api/settings'; +import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; +import { Feature } from '../../../../../types/features'; +import { ExtendedSettingDefinition } from '../../../../../types/settings'; +import { Dict } from '../../../../../types/types'; + +const SAML = 'saml'; + +export const SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled'; +export const SAML_GROUP_NAME = 'sonar.auth.saml.group.name'; +export const SAML_SCIM_DEPRECATED = 'sonar.scim.enabled'; +const SAML_PROVIDER_NAME = 'sonar.auth.saml.providerName'; +const SAML_LOGIN_URL = 'sonar.auth.saml.loginUrl'; + +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', + SAML_SCIM_DEPRECATED, +]; + +export interface SamlSettingValue { + key: string; + mandatory: boolean; + isNotSet: boolean; + value?: string; + newValue?: string | boolean; + definition: ExtendedSettingDefinition; +} + +export default function useSamlConfiguration(definitions: ExtendedSettingDefinition[]) { + const [loading, setLoading] = React.useState(true); + const [scimStatus, setScimStatus] = React.useState<boolean>(false); + const [values, setValues] = React.useState<Dict<SamlSettingValue>>({}); + const [newScimStatus, setNewScimStatus] = React.useState<boolean>(); + const hasScim = React.useContext(AvailableFeaturesContext).includes(Feature.Scim); + + const onReload = React.useCallback(async () => { + const samlDefinition = definitions.filter((def) => def.subCategory === SAML); + const keys = samlDefinition.map((definition) => definition.key); + + setLoading(true); + + try { + const values = await getValues({ + keys, + }); + + setValues( + keyBy( + samlDefinition.map((definition) => ({ + key: definition.key, + value: values.find((v) => v.key === definition.key)?.value, + mandatory: !OPTIONAL_FIELDS.includes(definition.key), + isNotSet: values.find((v) => v.key === definition.key) === undefined, + definition, + })), + 'key' + ) + ); + + if (hasScim) { + setScimStatus(await fetchIsScimEnabled()); + } + } finally { + setLoading(false); + } + }, [...definitions]); + + React.useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + await onReload(); + })(); + }, [...definitions]); + + const setNewValue = (key: string, newValue?: string | boolean) => { + const newValues = { + ...values, + [key]: { + key, + newValue, + mandatory: values[key]?.mandatory, + isNotSet: values[key]?.isNotSet, + value: values[key]?.value, + definition: values[key]?.definition, + }, + }; + setValues(newValues); + }; + + const canBeSave = every( + Object.values(values).filter((v) => v.mandatory), + (v) => + (v.newValue !== undefined && !isEmpty(v.newValue)) || + (!v.isNotSet && v.newValue === undefined) + ); + + const hasConfiguration = every( + Object.values(values).filter((v) => v.mandatory), + (v) => !v.isNotSet + ); + + const name = values[SAML_PROVIDER_NAME]?.value; + const url = values[SAML_LOGIN_URL]?.value; + const samlEnabled = values[SAML_ENABLED_FIELD]?.value === 'true'; + const groupValue = values[SAML_GROUP_NAME]; + + const setNewGroupSetting = (value?: string) => { + setNewValue(SAML_GROUP_NAME, value); + }; + + const hasScimConfigChange = + newScimStatus !== undefined && + groupValue && + (newScimStatus !== scimStatus || + (groupValue.newValue !== undefined && (groupValue.value ?? '') !== groupValue.newValue)); + + return { + hasScim, + scimStatus, + loading, + samlEnabled, + name, + url, + groupValue, + hasConfiguration, + canBeSave, + values, + setNewValue, + onReload, + hasScimConfigChange, + newScimStatus, + setNewScimStatus, + setNewGroupSetting, + }; +} diff --git a/server/sonar-web/src/main/js/apps/settings/styles.css b/server/sonar-web/src/main/js/apps/settings/styles.css index ecf27c1be01..d116f640ea7 100644 --- a/server/sonar-web/src/main/js/apps/settings/styles.css +++ b/server/sonar-web/src/main/js/apps/settings/styles.css @@ -229,3 +229,49 @@ padding: 16px; overflow-wrap: break-word; } + +.saml-enabled { + color: var(--success500); +} + +.saml-no-config { + background-color: var(--neutral50); + color: var(--blacka60); +} + +.saml-configuration .radio-card { + width: 50%; + background-color: var(--neutral50); + border: 1px solid var(--neutral200); +} + +.saml-configuration .radio-card.selected { + background-color: var(--info50); + border: 1px solid var(--info500); +} + +.saml-configuration .radio-card:hover:not(.selected) { + border: 1px solid var(--info500); +} + +.saml-configuration fieldset > div { + justify-content: space-between; +} + +.saml-configuration .radio-card-header { + justify-content: space-around; +} + +.saml-configuration .radio-card-body { + justify-content: flex-start; +} + +.saml-configuration .settings-definition-left { + width: 50%; +} + +.saml-configuration .settings-definition-right { + display: flex; + align-items: center; + width: 50%; +} |