aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src/main/js/apps/settings
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web/src/main/js/apps/settings')
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/DefinitionDescription.tsx56
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx33
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/PageHeader.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/SubCategoryDefinitionsList.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx94
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx609
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx150
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx23
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx25
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx248
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts160
-rw-r--r--server/sonar-web/src/main/js/apps/settings/styles.css46
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%;
+}