diff options
author | Mathieu Suen <mathieu.suen@sonarsource.com> | 2023-04-24 17:18:45 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-05-11 20:03:13 +0000 |
commit | 47d837b5736ca2e0d71520e9d2cc9deec3388b3f (patch) | |
tree | fddbf0d67d2cf97d637ef19f7743987a3bcbd19f | |
parent | c84b4032a56be146536423221b5c82aea7c418e3 (diff) | |
download | sonarqube-47d837b5736ca2e0d71520e9d2cc9deec3388b3f.tar.gz sonarqube-47d837b5736ca2e0d71520e9d2cc9deec3388b3f.zip |
SONAR-19084 Improve github authentication setting
16 files changed, 452 insertions, 170 deletions
diff --git a/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx b/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx index 674dc5bd20e..450c50989d7 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/CreateApplicationForm.tsx @@ -19,9 +19,9 @@ */ import * as React from 'react'; import { createApplication } from '../../../api/application'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import Radio from '../../../components/controls/Radio'; import SimpleModal from '../../../components/controls/SimpleModal'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; @@ -104,7 +104,7 @@ export default class CreateApplicationForm extends React.PureComponent<Props, St size="small" > {({ onCloseClick, onFormSubmit, submitting }) => ( - <form className="views-form" onSubmit={onFormSubmit}> + <form onSubmit={onFormSubmit}> <div className="modal-head"> <h2>{header}</h2> </div> diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap index eca852deb51..a193f2cd305 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/CreateApplicationForm-test.tsx.snap @@ -18,7 +18,6 @@ exports[`should render correctly: form 1`] = ` size="small" > <form - className="views-form" onSubmit={[Function]} > <div diff --git a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionFormRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionFormRenderer.tsx index 238b2585192..5e95c87d2c3 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionFormRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/almIntegration/AlmBindingDefinitionFormRenderer.tsx @@ -18,8 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; import Modal from '../../../../components/controls/Modal'; +import { ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; import { Alert } from '../../../../components/ui/Alert'; import DeferredSpinner from '../../../../components/ui/DeferredSpinner'; import { translate } from '../../../../helpers/l10n'; @@ -112,7 +112,7 @@ export default class AlmBindingDefinitionFormRenderer extends React.PureComponen shouldCloseOnOverlayClick={false} size="medium" > - <form className="views-form" onSubmit={handleSubmit}> + <form onSubmit={handleSubmit}> <div className="modal-head"> <h2>{header}</h2> </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 361c2495e63..a10aa8293f2 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 @@ -37,7 +37,8 @@ import { Feature } from '../../../../types/features'; import { ExtendedSettingDefinition } from '../../../../types/settings'; import { AUTHENTICATION_CATEGORY } from '../../constants'; import CategoryDefinitionsList from '../CategoryDefinitionsList'; -import SamlAuthentication, { SAML } from './SamlAuthentication'; +import GithubAithentication from './GithubAutheticationTab'; +import SamlAuthenticationTab, { SAML } from './SamlAuthenticationTab'; interface Props { definitions: ExtendedSettingDefinition[]; @@ -52,7 +53,7 @@ export type AuthenticationTabs = | AlmKeys.GitLab | AlmKeys.BitbucketServer; -const DOCUMENTATION_LINK_SUFFIXES = { +export const DOCUMENTATION_LINK_SUFFIXES = { [SAML]: 'saml/overview', [AlmKeys.GitHub]: 'github', [AlmKeys.GitLab]: 'gitlab', @@ -109,7 +110,7 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { </> ), }, - ]; + ] as const; return ( <> @@ -151,7 +152,10 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { {tabs.map((tab) => ( <div style={{ - maxHeight: tab.key !== SAML ? `calc(100vh - ${top + HEIGHT_ADJUSTMENT}px)` : '', + maxHeight: + tab.key !== SAML && tab.key !== AlmKeys.GitHub + ? `calc(100vh - ${top + HEIGHT_ADJUSTMENT}px)` + : '', }} className={classNames('bordered overflow-y-auto tabbed-definitions', { hidden: currentTab !== tab.key, @@ -162,9 +166,19 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { id={getTabPanelId(tab.key)} > <div className="big-padded-top big-padded-left big-padded-right"> - {tab.key === SAML && <SamlAuthentication definitions={definitions} />} + {tab.key === SAML && ( + <SamlAuthenticationTab + definitions={definitions.filter((def) => def.subCategory === SAML)} + /> + )} + + {tab.key === AlmKeys.GitHub && ( + <GithubAithentication + definitions={definitions.filter((def) => def.subCategory === AlmKeys.GitHub)} + /> + )} - {tab.key !== SAML && ( + {tab.key !== SAML && tab.key !== AlmKeys.GitHub && ( <> <Alert variant="info"> <FormattedMessage @@ -174,7 +188,7 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) { link: ( <DocLink to={`/instance-administration/authentication/${ - DOCUMENTATION_LINK_SUFFIXES[tab.key as AuthenticationTabs] + DOCUMENTATION_LINK_SUFFIXES[tab.key] }/`} > {translate('settings.authentication.help.link')} 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/AuthenticationFormField.tsx index d86afef207d..85706584951 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/AuthenticationFormField.tsx @@ -23,8 +23,9 @@ import ValidationInput, { } from '../../../../components/controls/ValidationInput'; import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker'; import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; -import SamlSecuredField from './SamlSecuredField'; -import SamlToggleField from './SamlToggleField'; +import { isSecuredDefinition } from '../../utils'; +import AuthenticationSecuredField from './AuthenticationSecuredField'; +import AuthenticationToggleField from './AuthenticationToggleField'; interface SamlToggleFieldProps { settingValue?: string | boolean; @@ -35,7 +36,7 @@ interface SamlToggleFieldProps { error?: string; } -export default function SamlFormField(props: SamlToggleFieldProps) { +export default function AuthenticationFormField(props: SamlToggleFieldProps) { const { mandatory = false, definition, settingValue, isNotSet, error } = props; return ( @@ -50,23 +51,22 @@ export default function SamlFormField(props: SamlToggleFieldProps) { )} </div> <div className="settings-definition-right big-padded-top display-flex-column"> - {definition.type === SettingType.PASSWORD && ( - <SamlSecuredField + {isSecuredDefinition(definition) && ( + <AuthenticationSecuredField definition={definition} settingValue={String(settingValue ?? '')} onFieldChange={props.onFieldChange} isNotSet={isNotSet} /> )} - {definition.type === SettingType.BOOLEAN && ( - <SamlToggleField + {!isSecuredDefinition(definition) && definition.type === SettingType.BOOLEAN && ( + <AuthenticationToggleField definition={definition} settingValue={settingValue} - toggleDisabled={false} onChange={(value) => props.onFieldChange(definition.key, value)} /> )} - {definition.type === undefined && ( + {!isSecuredDefinition(definition) && definition.type === undefined && ( <ValidationInput error={error} errorPlacement={ValidationInputErrorPlacement.Bottom} 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/AuthenticationSecuredField.tsx index a7177a2a114..ed1345b1398 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/AuthenticationSecuredField.tsx @@ -20,7 +20,7 @@ import React, { useEffect } from 'react'; import { ButtonLink } from '../../../../components/controls/buttons'; import { translate } from '../../../../helpers/l10n'; -import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; import { isSecuredDefinition } from '../../utils'; interface SamlToggleFieldProps { @@ -31,7 +31,7 @@ interface SamlToggleFieldProps { isNotSet: boolean; } -export default function SamlSecuredField(props: SamlToggleFieldProps) { +export default function AuthenticationSecuredField(props: SamlToggleFieldProps) { const { settingValue, definition, optional = true, isNotSet } = props; const [showSecretField, setShowSecretField] = React.useState( !isNotSet && isSecuredDefinition(definition) @@ -43,17 +43,28 @@ export default function SamlSecuredField(props: SamlToggleFieldProps) { return ( <> - {!showSecretField && ( - <textarea - className="width-100" - id={definition.key} - maxLength={4000} - onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)} - required={!optional} - rows={5} - value={settingValue ?? ''} - /> - )} + {!showSecretField && + (definition.type === SettingType.TEXT ? ( + <textarea + className="width-100" + id={definition.key} + maxLength={4000} + onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)} + required={!optional} + rows={5} + value={settingValue ?? ''} + /> + ) : ( + <input + className="width-100" + id={definition.key} + maxLength={4000} + name={definition.key} + onChange={(e) => props.onFieldChange(definition.key, e.currentTarget.value)} + type="text" + value={String(settingValue ?? '')} + /> + ))} {showSecretField && ( <div> <p>{translate('settings.almintegration.form.secret.field')}</p> 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/AuthenticationToggleField.tsx index a7c787c2cd0..40b71d67ec9 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/AuthenticationToggleField.tsx @@ -22,21 +22,13 @@ import Toggle from '../../../../components/controls/Toggle'; import { ExtendedSettingDefinition } from '../../../../types/settings'; interface SamlToggleFieldProps { - toggleDisabled: boolean; onChange: (value: boolean) => void; settingValue?: string | boolean; definition: ExtendedSettingDefinition; } -export default function SamlToggleField(props: SamlToggleFieldProps) { - const { toggleDisabled, settingValue, definition } = props; +export default function AuthenticationToggleField(props: SamlToggleFieldProps) { + const { settingValue, definition } = props; - return ( - <Toggle - name={definition.key} - onChange={props.onChange} - value={settingValue ?? ''} - disabled={toggleDisabled} - /> - ); + return <Toggle name={definition.key} onChange={props.onChange} value={settingValue ?? ''} />; } 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/ConfigurationForm.tsx index 028429f6031..5920471a229 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlConfigurationForm.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx @@ -22,28 +22,26 @@ 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 { ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; 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'; +import { AuthenticationTabs, DOCUMENTATION_LINK_SUFFIXES } from './Authentication'; +import AuthenticationFormField from './AuthenticationFormField'; +import { SettingValue } from './hook/useConfiguration'; interface Props { create: boolean; loading: boolean; - values: Dict<SamlSettingValue>; + values: Dict<SettingValue>; setNewValue: (key: string, value: string | boolean) => void; canBeSave: boolean; onClose: () => void; onReload: () => Promise<void>; + tab: AuthenticationTabs; + excludedField: string[]; } interface ErrorValue { @@ -51,15 +49,11 @@ interface ErrorValue { 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; +export default function ConfigurationForm(props: Props) { + const { create, loading, values, setNewValue, canBeSave, tab, excludedField } = props; const [errors, setErrors] = React.useState<Dict<ErrorValue>>({}); - const headerLabel = translate('settings.authentication.saml.form', create ? 'create' : 'edit'); + const headerLabel = translate('settings.authentication.form', create ? 'create' : 'edit', tab); const handleSubmit = async (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); @@ -95,14 +89,14 @@ export default function SamlConfigurationForm(props: Props) { return ( <Modal contentLabel={headerLabel} shouldCloseOnOverlayClick={false} size="medium"> - <form className="views-form create-saml-form" onSubmit={handleSubmit}> + <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')} + ariaLabel={translate('settings.authentication.form.loading')} > <Alert variant="info"> <FormattedMessage @@ -110,7 +104,9 @@ export default function SamlConfigurationForm(props: Props) { defaultMessage={translate('settings.authentication.help')} values={{ link: ( - <DocLink to="/instance-administration/authentication/saml/overview/"> + <DocLink + to={`/instance-administration/authentication/${DOCUMENTATION_LINK_SUFFIXES[tab]}/`} + > {translate('settings.authentication.help.link')} </DocLink> ), @@ -118,12 +114,12 @@ export default function SamlConfigurationForm(props: Props) { /> </Alert> {Object.values(values).map((val) => { - if (SAML_EXCLUDED_FIELD.includes(val.key)) { + if (excludedField.includes(val.key)) { return null; } return ( <div key={val.key}> - <SamlFormField + <AuthenticationFormField settingValue={values[val.key]?.newValue ?? values[val.key]?.value} definition={val.definition} mandatory={val.mandatory} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAutheticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAutheticationTab.tsx new file mode 100644 index 00000000000..837ef38530e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAutheticationTab.tsx @@ -0,0 +1,142 @@ +/* + * 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 React from 'react'; +import { setSettingValue } from '../../../../api/settings'; +import { Button } from '../../../../components/controls/buttons'; +import CheckIcon from '../../../../components/icons/CheckIcon'; +import DeleteIcon from '../../../../components/icons/DeleteIcon'; +import EditIcon from '../../../../components/icons/EditIcon'; +import { translate } from '../../../../helpers/l10n'; +import { AlmKeys } from '../../../../types/alm-settings'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; +import ConfigurationForm from './ConfigurationForm'; +import useGithubConfiguration, { GITHUB_ENABLED_FIELD } from './hook/useGithubConfiguration'; + +interface SamlAuthenticationProps { + definitions: ExtendedSettingDefinition[]; +} + +const GITHUB_EXCLUDED_FIELD = [ + 'sonar.auth.github.enabled', + 'sonar.auth.github.groupsSync', + 'sonar.auth.github.allowUsersToSignUp', +]; + +export default function GithubAithentication(props: SamlAuthenticationProps) { + const [showEditModal, setShowEditModal] = React.useState(false); + const { + hasConfiguration, + loading, + values, + setNewValue, + canBeSave, + reload, + url, + appId, + enabled, + deleteConfiguration, + } = useGithubConfiguration(props.definitions); + + const handleCreateConfiguration = () => { + setShowEditModal(true); + }; + + const handleCancelConfiguration = () => { + setShowEditModal(false); + }; + + const handleToggleEnable = async () => { + const value = values[GITHUB_ENABLED_FIELD]; + await setSettingValue(value.definition, !enabled); + await reload(); + }; + + return ( + <div className="saml-configuration"> + <div className="spacer-bottom display-flex-space-between display-flex-center"> + <h4>{translate('settings.authentication.github.configuration')}</h4> + + {!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.github.form.not_configured')} + </div> + ) : ( + <> + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + <div> + <h5>{appId}</h5> + <p>{url}</p> + <p className="big-spacer-top big-spacer-bottom"> + {enabled ? ( + <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" onClick={handleToggleEnable}> + {enabled + ? translate('settings.authentication.saml.form.disable') + : translate('settings.authentication.saml.form.enable')} + </Button> + </div> + <div> + <Button className="spacer-right" onClick={handleCreateConfiguration}> + <EditIcon /> + {translate('settings.authentication.form.edit')} + </Button> + <Button className="button-red" disabled={enabled} onClick={deleteConfiguration}> + <DeleteIcon /> + {translate('settings.authentication.form.delete')} + </Button> + </div> + </div> + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + Provisioning TODO + </div> + </> + )} + + {showEditModal && ( + <ConfigurationForm + tab={AlmKeys.GitHub} + excludedField={GITHUB_EXCLUDED_FIELD} + loading={loading} + values={values} + setNewValue={setNewValue} + canBeSave={canBeSave} + onClose={handleCancelConfiguration} + create={!hasConfiguration} + onReload={reload} + /> + )} + </div> + ); +} 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/SamlAuthenticationTab.tsx index e6e123b5f39..43ab887e349 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/SamlAuthenticationTab.tsx @@ -40,8 +40,12 @@ import { getBaseUrl } from '../../../../helpers/system'; import { ExtendedSettingDefinition } from '../../../../types/settings'; import { getPropertyName } from '../../utils'; import DefinitionDescription from '../DefinitionDescription'; -import SamlConfigurationForm from './SamlConfigurationForm'; -import useSamlConfiguration, { SAML_ENABLED_FIELD } from './hook/useLoadSamlSettings'; +import ConfigurationForm from './ConfigurationForm'; +import useSamlConfiguration, { + SAML_ENABLED_FIELD, + SAML_GROUP_NAME, + SAML_SCIM_DEPRECATED, +} from './hook/useLoadSamlSettings'; interface SamlAuthenticationProps { definitions: ExtendedSettingDefinition[]; @@ -50,8 +54,9 @@ interface SamlAuthenticationProps { export const SAML = 'saml'; const CONFIG_TEST_PATH = '/saml/validation_init'; +const SAML_EXCLUDED_FIELD = [SAML_ENABLED_FIELD, SAML_GROUP_NAME, SAML_SCIM_DEPRECATED]; -export default function SamlAuthentication(props: SamlAuthenticationProps) { +export default function SamlAuthenticationTab(props: SamlAuthenticationProps) { const { definitions } = props; const [showEditModal, setShowEditModal] = React.useState(false); const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = React.useState(false); @@ -71,14 +76,10 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { newScimStatus, setNewScimStatus, setNewGroupSetting, - onReload, + reload, + deleteConfiguration, } = useSamlConfiguration(definitions); - const handleDeleteConfiguration = async () => { - await resetSettingValue({ keys: Object.keys(values).join(',') }); - await onReload(); - }; - const handleCreateConfiguration = () => { setShowEditModal(true); }; @@ -90,7 +91,7 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { const handleToggleEnable = async () => { const value = values[SAML_ENABLED_FIELD]; await setSettingValue(value.definition, !samlEnabled); - await onReload(); + await reload(); }; const handleSaveGroup = async () => { @@ -100,7 +101,7 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { } else { await setSettingValue(groupValue.definition, groupValue.newValue); } - await onReload(); + await reload(); } }; @@ -111,7 +112,7 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { await deactivateScim(); await handleSaveGroup(); } - await onReload(); + await reload(); }; return ( @@ -167,11 +168,7 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { <EditIcon /> {translate('settings.authentication.form.edit')} </Button> - <Button - className="button-red" - disabled={samlEnabled} - onClick={handleDeleteConfiguration} - > + <Button className="button-red" disabled={samlEnabled} onClick={deleteConfiguration}> <DeleteIcon /> {translate('settings.authentication.form.delete')} </Button> @@ -318,14 +315,16 @@ export default function SamlAuthentication(props: SamlAuthenticationProps) { </> )} {showEditModal && ( - <SamlConfigurationForm + <ConfigurationForm + tab={SAML} + excludedField={SAML_EXCLUDED_FIELD} loading={loading} values={values} setNewValue={setNewValue} canBeSave={canBeSave} onClose={handleCancelConfiguration} create={!hasConfiguration} - onReload={onReload} + onReload={reload} /> )} </div> 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 b8136d361e4..3b7b730add5 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 @@ -84,7 +84,7 @@ const ui = { createConfiguration: async (user: UserEvent) => { const { saml } = ui; await act(async () => { - await user.click(await saml.createConfigButton.find()); + await user.click((await saml.createConfigButton.findAll())[0]); }); await saml.fillForm(user); await act(async () => { @@ -135,7 +135,7 @@ describe('SAML tab', () => { const user = userEvent.setup(); renderAuthentication(); - await user.click(await saml.createConfigButton.find()); + await user.click((await saml.createConfigButton.findAll())[0]); expect(saml.saveConfigButton.get()).toBeDisabled(); await saml.fillForm(user); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useConfiguration.ts b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useConfiguration.ts new file mode 100644 index 00000000000..65596864b49 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useConfiguration.ts @@ -0,0 +1,125 @@ +/* + * 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 { getValues, resetSettingValue } from '../../../../../api/settings'; +import { ExtendedSettingDefinition } from '../../../../../types/settings'; +import { Dict } from '../../../../../types/types'; + +export interface SettingValue { + key: string; + mandatory: boolean; + isNotSet: boolean; + value?: string; + newValue?: string | boolean; + definition: ExtendedSettingDefinition; +} + +export default function useConfiguration( + definitions: ExtendedSettingDefinition[], + optionalFields: string[] +) { + const [loading, setLoading] = React.useState(true); + const [values, setValues] = React.useState<Dict<SettingValue>>({}); + + const reload = React.useCallback(async () => { + const keys = definitions.map((definition) => definition.key); + + setLoading(true); + + try { + const values = await getValues({ + keys, + }); + + setValues( + keyBy( + definitions.map((definition) => ({ + key: definition.key, + value: values.find((v) => v.key === definition.key)?.value, + mandatory: !optionalFields.includes(definition.key), + isNotSet: values.find((v) => v.key === definition.key) === undefined, + definition, + })), + 'key' + ) + ); + } finally { + setLoading(false); + } + }, [...definitions]); + + React.useEffect(() => { + (async () => { + await reload(); + })(); + }, [...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 deleteConfiguration = React.useCallback(async () => { + await resetSettingValue({ keys: Object.keys(values).join(',') }); + await reload(); + }, [reload, values]); + + const isValueChange = React.useCallback( + (setting: string) => { + const value = values[setting]; + return value && value.newValue !== undefined && (value.value ?? '') !== value.newValue; + }, + [values] + ); + + return { + values, + reload, + setNewValue, + canBeSave, + loading, + hasConfiguration, + isValueChange, + deleteConfiguration, + }; +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts new file mode 100644 index 00000000000..05ec87e7c7d --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts @@ -0,0 +1,54 @@ +/* + * 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 { ExtendedSettingDefinition } from '../../../../../types/settings'; +import useConfiguration from './useConfiguration'; + +export const GITHUB_ENABLED_FIELD = 'sonar.auth.github.enabled'; +export const GITHUB_APP_ID_FIELD = 'sonar.auth.github.appId'; +export const GITHUB_API_URL_FIELD = 'sonar.auth.github.apiUrl'; + +const OPTIONAL_FIELDS = [ + GITHUB_ENABLED_FIELD, + 'sonar.auth.github.organizations', + 'sonar.auth.github.allowUsersToSignUp', + 'sonar.auth.github.groupsSync', + 'sonar.auth.github.organizations', +]; + +export interface SamlSettingValue { + key: string; + mandatory: boolean; + isNotSet: boolean; + value?: string; + newValue?: string | boolean; + definition: ExtendedSettingDefinition; +} + +export default function useGithubConfiguration(definitions: ExtendedSettingDefinition[]) { + const config = useConfiguration(definitions, OPTIONAL_FIELDS); + + const { values } = config; + + const enabled = values[GITHUB_ENABLED_FIELD]?.value === 'true'; + const appId = values[GITHUB_APP_ID_FIELD]?.value; + const url = values[GITHUB_API_URL_FIELD]?.value; + + return { ...config, url, enabled, appId }; +} 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 index af29e0ea0c5..7c06147aacf 100644 --- 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 @@ -17,15 +17,12 @@ * 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 { fetchIsScimEnabled } 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'; +import useConfiguration from './useConfiguration'; export const SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled'; export const SAML_GROUP_NAME = 'sonar.auth.saml.group.name'; @@ -42,87 +39,28 @@ const OPTIONAL_FIELDS = [ 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 { + loading, + reload: reloadConfig, + values, + setNewValue, + canBeSave, + hasConfiguration, + deleteConfiguration, + isValueChange, + } = useConfiguration(definitions, OPTIONAL_FIELDS); - 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' - ) - ); - + React.useEffect(() => { + (async () => { 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 - ); + }, [hasScim]); const name = values[SAML_PROVIDER_NAME]?.value; const url = values[SAML_LOGIN_URL]?.value; @@ -134,10 +72,12 @@ export default function useSamlConfiguration(definitions: ExtendedSettingDefinit }; const hasScimConfigChange = - newScimStatus !== undefined && - groupValue && - (newScimStatus !== scimStatus || - (groupValue.newValue !== undefined && (groupValue.value ?? '') !== groupValue.newValue)); + isValueChange(SAML_GROUP_NAME) || (newScimStatus !== undefined && newScimStatus !== scimStatus); + + const reload = React.useCallback(async () => { + await reloadConfig(); + setScimStatus(await fetchIsScimEnabled()); + }, [reloadConfig]); return { hasScim, @@ -151,10 +91,11 @@ export default function useSamlConfiguration(definitions: ExtendedSettingDefinit canBeSave, values, setNewValue, - onReload, + reload, hasScimConfigChange, newScimStatus, setNewScimStatus, setNewGroupSetting, + deleteConfiguration, }; } diff --git a/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx b/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx index 1df7333356d..1794ecd47c7 100644 --- a/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx +++ b/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx @@ -25,7 +25,7 @@ export interface BoxedTabsProps<K extends string | number> { className?: string; onSelect: (key: K) => void; selected?: K; - tabs: Array<{ key: K; label: React.ReactNode }>; + tabs: ReadonlyArray<{ key: K; label: React.ReactNode }>; } const TabContainer = styled.div` diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 4702ad16e86..0416859153b 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1323,6 +1323,15 @@ settings.authentication.help.link=documentation settings.authentication.form.create=Create configuration settings.authentication.form.edit=Edit settings.authentication.form.delete=Delete +settings.authentication.form.loading=Loading configuration + +settings.authentication.form.create.saml=New SAML configuration +settings.authentication.form.edit.saml=Edit SAML configuration +settings.authentication.form.create.github=New Github configuration +settings.authentication.form.edit.github=Edit Github configuration + +settings.authentication.github.configuration=Github Configuration +settings.authentication.github.form.not_configured=Github App is not configured settings.authentication.saml.configuration=SAML Configuration settings.authentication.saml.confirm.scim=Switch to automatic provisioning settings.authentication.saml.confirm.jit=Switch to Just-in-Time provisioning |