diff options
author | guillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com> | 2023-04-26 17:08:11 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-05-11 20:03:14 +0000 |
commit | 07b8350a8821607a55f584bf47ea85a124f9c9a0 (patch) | |
tree | b6d5b166565c1f7440b27ac72a3030a3b21cda8b /server/sonar-web | |
parent | 63c1441080c51dbc2e390c3d826b2b8bcf73f10a (diff) | |
download | sonarqube-07b8350a8821607a55f584bf47ea85a124f9c9a0.tar.gz sonarqube-07b8350a8821607a55f584bf47ea85a124f9c9a0.zip |
SONAR-19084 GitHub Provisioning in Authentication settings
Diffstat (limited to 'server/sonar-web')
14 files changed, 638 insertions, 101 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts index f4986f2d69b..a248742588c 100644 --- a/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts @@ -22,8 +22,11 @@ import { mockSettingValue } from '../../helpers/mocks/settings'; import { BranchParameters } from '../../types/branch-like'; import { SettingDefinition, SettingValue } from '../../types/settings'; import { + activateGithubProvisioning, activateScim, + deactivateGithubProvisioning, deactivateScim, + fetchIsGithubProvisioningEnabled, fetchIsScimEnabled, getValues, resetSettingValue, @@ -33,6 +36,7 @@ import { export default class AuthenticationServiceMock { settingValues: SettingValue[]; scimStatus: boolean; + githubProvisioningStatus: boolean; defaulSettingValues: SettingValue[] = [ mockSettingValue({ key: 'test1', value: '' }), mockSettingValue({ key: 'test2', value: 'test2' }), @@ -61,13 +65,22 @@ export default class AuthenticationServiceMock { constructor() { this.settingValues = cloneDeep(this.defaulSettingValues); this.scimStatus = false; + this.githubProvisioningStatus = false; jest.mocked(getValues).mockImplementation(this.handleGetValues); jest.mocked(setSettingValue).mockImplementation(this.handleSetValue); jest.mocked(resetSettingValue).mockImplementation(this.handleResetValue); jest.mocked(activateScim).mockImplementation(this.handleActivateScim); jest.mocked(deactivateScim).mockImplementation(this.handleDeactivateScim); - jest.mocked(fetchIsScimEnabled).mockImplementation(this.handleFetchIsScimEnabled); + jest + .mocked(activateGithubProvisioning) + .mockImplementation(this.handleActivateGithubProvisioning); + jest + .mocked(deactivateGithubProvisioning) + .mockImplementation(this.handleDeactivateGithubProvisioning); + jest + .mocked(fetchIsGithubProvisioningEnabled) + .mockImplementation(this.handleFetchIsGithubProvisioningEnabled); } handleActivateScim = () => { @@ -84,6 +97,20 @@ export default class AuthenticationServiceMock { return Promise.resolve(this.scimStatus); }; + handleActivateGithubProvisioning = () => { + this.githubProvisioningStatus = true; + return Promise.resolve(); + }; + + handleDeactivateGithubProvisioning = () => { + this.githubProvisioningStatus = false; + return Promise.resolve(); + }; + + handleFetchIsGithubProvisioningEnabled = () => { + return Promise.resolve(this.githubProvisioningStatus); + }; + handleGetValues = ( data: { keys: string[]; component?: string } & BranchParameters ): Promise<SettingValue[]> => { diff --git a/server/sonar-web/src/main/js/api/settings.ts b/server/sonar-web/src/main/js/api/settings.ts index d741cb0bc00..8e17b5e6894 100644 --- a/server/sonar-web/src/main/js/api/settings.ts +++ b/server/sonar-web/src/main/js/api/settings.ts @@ -130,3 +130,17 @@ export function activateScim(): Promise<void> { export function deactivateScim(): Promise<void> { return post('/api/scim_management/disable').catch(throwGlobalError); } + +export function fetchIsGithubProvisioningEnabled(): Promise<boolean> { + return getJSON('/api/github_provisioning/status') + .then((r) => r.enabled) + .catch(throwGlobalError); +} + +export function activateGithubProvisioning(): Promise<void> { + return post('/api/github_provisioning/enable').catch(throwGlobalError); +} + +export function deactivateGithubProvisioning(): Promise<void> { + return post('/api/github_provisioning/disable').catch(throwGlobalError); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx index 85706584951..f4197d89ebc 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx @@ -24,14 +24,15 @@ import ValidationInput, { import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker'; import { ExtendedSettingDefinition, SettingType } from '../../../../types/settings'; import { isSecuredDefinition } from '../../utils'; +import AuthenticationMultiValueField from './AuthenticationMultiValuesField'; import AuthenticationSecuredField from './AuthenticationSecuredField'; import AuthenticationToggleField from './AuthenticationToggleField'; interface SamlToggleFieldProps { - settingValue?: string | boolean; + settingValue?: string | boolean | string[]; definition: ExtendedSettingDefinition; mandatory?: boolean; - onFieldChange: (key: string, value: string | boolean) => void; + onFieldChange: (key: string, value: string | boolean | string[]) => void; isNotSet: boolean; error?: string; } @@ -51,6 +52,13 @@ export default function AuthenticationFormField(props: SamlToggleFieldProps) { )} </div> <div className="settings-definition-right big-padded-top display-flex-column"> + {definition.multiValues && ( + <AuthenticationMultiValueField + definition={definition} + settingValue={settingValue as string[]} + onFieldChange={(value) => props.onFieldChange(definition.key, value)} + /> + )} {isSecuredDefinition(definition) && ( <AuthenticationSecuredField definition={definition} @@ -62,28 +70,30 @@ export default function AuthenticationFormField(props: SamlToggleFieldProps) { {!isSecuredDefinition(definition) && definition.type === SettingType.BOOLEAN && ( <AuthenticationToggleField definition={definition} - settingValue={settingValue} + settingValue={settingValue as string | boolean} onChange={(value) => props.onFieldChange(definition.key, value)} /> )} - {!isSecuredDefinition(definition) && definition.type === undefined && ( - <ValidationInput - error={error} - errorPlacement={ValidationInputErrorPlacement.Bottom} - isValid={false} - isInvalid={Boolean(error)} - > - <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 ?? '')} - /> - </ValidationInput> - )} + {!isSecuredDefinition(definition) && + definition.type === undefined && + !definition.multiValues && ( + <ValidationInput + error={error} + errorPlacement={ValidationInputErrorPlacement.Bottom} + isValid={false} + isInvalid={Boolean(error)} + > + <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 ?? '')} + /> + </ValidationInput> + )} </div> </div> ); diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx new file mode 100644 index 00000000000..1dfd21f949f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx @@ -0,0 +1,85 @@ +/* + * 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 * as React from 'react'; +import { DeleteButton } from '../../../../components/controls/buttons'; +import { translateWithParameters } from '../../../../helpers/l10n'; +import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { getPropertyName } from '../../utils'; + +interface Props { + onFieldChange: (value: string[]) => void; + settingValue?: string[]; + definition: ExtendedSettingDefinition; +} + +export default function AuthenticationMultiValueField(props: Props) { + const { settingValue = [], definition } = props; + + const displayValue = [...settingValue, '']; + + const handleSingleInputChange = (index: number, value: string) => { + const newValue = [...settingValue]; + newValue.splice(index, 1, value); + props.onFieldChange(newValue); + }; + + const handleDeleteValue = (index: number) => { + const newValue = [...settingValue]; + newValue.splice(index, 1); + props.onFieldChange(newValue); + }; + + return ( + <div> + <ul> + {displayValue.map((value, index) => { + const isNotLast = index !== displayValue.length - 1; + return ( + <li className="spacer-bottom" key={index}> + <input + className="width-80" + id={definition.key} + maxLength={4000} + name={definition.key} + onChange={(e) => handleSingleInputChange(index, e.currentTarget.value)} + type="text" + value={displayValue[index]} + /> + + {isNotLast && ( + <div className="display-inline-block spacer-left"> + <DeleteButton + className="js-remove-value" + aria-label={translateWithParameters( + 'settings.definition.delete_value', + getPropertyName(definition), + value + )} + onClick={() => handleDeleteValue(index)} + /> + </div> + )} + </li> + ); + })} + </ul> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx index 40b71d67ec9..cb82310df92 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx @@ -30,5 +30,12 @@ interface SamlToggleFieldProps { export default function AuthenticationToggleField(props: SamlToggleFieldProps) { const { settingValue, definition } = props; - return <Toggle name={definition.key} onChange={props.onChange} value={settingValue ?? ''} />; + return ( + <Toggle + ariaLabel={definition.key} + name={definition.key} + onChange={props.onChange} + value={settingValue ?? ''} + /> + ); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx index 5920471a229..8f6406b8672 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx @@ -88,7 +88,13 @@ export default function ConfigurationForm(props: Props) { }; return ( - <Modal contentLabel={headerLabel} shouldCloseOnOverlayClick={false} size="medium"> + <Modal + contentLabel={headerLabel} + onRequestClose={props.onClose} + shouldCloseOnOverlayClick={false} + shouldCloseOnEsc={true} + size="medium" + > <form onSubmit={handleSubmit}> <div className="modal-head"> <h2>{headerLabel}</h2> 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 index 837ef38530e..70b7e03a676 100644 --- 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 @@ -17,19 +17,35 @@ * 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 { isEmpty } from 'lodash'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { + activateGithubProvisioning, + deactivateGithubProvisioning, + resetSettingValue, + setSettingValue, +} from '../../../../api/settings'; +import DocLink from '../../../../components/common/DocLink'; +import ConfirmModal from '../../../../components/controls/ConfirmModal'; +import RadioCard from '../../../../components/controls/RadioCard'; +import { Button, ResetButtonLink, SubmitButton } from '../../../../components/controls/buttons'; 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 { AlmKeys } from '../../../../types/alm-settings'; import { ExtendedSettingDefinition } from '../../../../types/settings'; +import { DOCUMENTATION_LINK_SUFFIXES } from './Authentication'; +import AuthenticationFormField from './AuthenticationFormField'; import ConfigurationForm from './ConfigurationForm'; -import useGithubConfiguration, { GITHUB_ENABLED_FIELD } from './hook/useGithubConfiguration'; +import useGithubConfiguration, { + GITHUB_ENABLED_FIELD, + GITHUB_JIT_FIELDS, +} from './hook/useGithubConfiguration'; -interface SamlAuthenticationProps { +interface GithubAuthenticationProps { definitions: ExtendedSettingDefinition[]; } @@ -37,12 +53,17 @@ const GITHUB_EXCLUDED_FIELD = [ 'sonar.auth.github.enabled', 'sonar.auth.github.groupsSync', 'sonar.auth.github.allowUsersToSignUp', + 'sonar.auth.github.organizations', ]; -export default function GithubAithentication(props: SamlAuthenticationProps) { - const [showEditModal, setShowEditModal] = React.useState(false); +export default function GithubAithentication(props: GithubAuthenticationProps) { + const [showEditModal, setShowEditModal] = useState(false); + const [showConfirmProvisioningModal, setShowConfirmProvisioningModal] = useState(false); + const { hasConfiguration, + hasGithubProvisioning, + githubProvisioningStatus, loading, values, setNewValue, @@ -52,6 +73,10 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { appId, enabled, deleteConfiguration, + newGithubProvisioningStatus, + setNewGithubProvisioningStatus, + hasGithubProvisioningConfigChange, + resetJitSetting, } = useGithubConfiguration(props.definitions); const handleCreateConfiguration = () => { @@ -62,6 +87,35 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { setShowEditModal(false); }; + const handleConfirmChangeProvisioning = async () => { + if (newGithubProvisioningStatus && newGithubProvisioningStatus !== githubProvisioningStatus) { + await activateGithubProvisioning(); + await reload(); + } else { + if (newGithubProvisioningStatus !== githubProvisioningStatus) { + await deactivateGithubProvisioning(); + } + await handleSaveGroup(); + } + }; + + const handleSaveGroup = async () => { + await Promise.all( + GITHUB_JIT_FIELDS.map(async (settingKey) => { + const value = values[settingKey]; + if (value.newValue !== undefined) { + // isEmpty always return true for booleans... + if (isEmpty(value.newValue) && typeof value.newValue !== 'boolean') { + await resetSettingValue({ keys: value.definition.key }); + } else { + await setSettingValue(value.definition, value.newValue); + } + } + }) + ); + await reload(); + }; + const handleToggleEnable = async () => { const value = values[GITHUB_ENABLED_FIELD]; await setSettingValue(value.definition, !enabled); @@ -69,7 +123,7 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { }; return ( - <div className="saml-configuration"> + <div className="authentication-configuration"> <div className="spacer-bottom display-flex-space-between display-flex-center"> <h4>{translate('settings.authentication.github.configuration')}</h4> @@ -82,7 +136,7 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { )} </div> {!hasConfiguration ? ( - <div className="big-padded text-center huge-spacer-bottom saml-no-config"> + <div className="big-padded text-center huge-spacer-bottom authentication-no-config"> {translate('settings.authentication.github.form.not_configured')} </div> ) : ( @@ -93,18 +147,18 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { <p>{url}</p> <p className="big-spacer-top big-spacer-bottom"> {enabled ? ( - <span className="saml-enabled spacer-left"> + <span className="authentication-enabled spacer-left"> <CheckIcon className="spacer-right" /> - {translate('settings.authentication.saml.form.enabled')} + {translate('settings.authentication.form.enabled')} </span> ) : ( - translate('settings.authentication.saml.form.not_enabled') + translate('settings.authentication.form.not_enabled') )} </p> <Button className="spacer-top" onClick={handleToggleEnable}> {enabled - ? translate('settings.authentication.saml.form.disable') - : translate('settings.authentication.saml.form.enable')} + ? translate('settings.authentication.form.disable') + : translate('settings.authentication.form.enable')} </Button> </div> <div> @@ -118,9 +172,128 @@ export default function GithubAithentication(props: SamlAuthenticationProps) { </Button> </div> </div> - <div className="spacer-bottom big-padded bordered display-flex-space-between"> - Provisioning TODO - </div> + {hasGithubProvisioning && ( + <div className="spacer-bottom big-padded bordered display-flex-space-between"> + <form + onSubmit={async (e) => { + e.preventDefault(); + if (newGithubProvisioningStatus !== githubProvisioningStatus) { + setShowConfirmProvisioningModal(true); + } else { + await handleSaveGroup(); + } + }} + > + <fieldset className="display-flex-column big-spacer-bottom"> + <label className="h5"> + {translate('settings.authentication.form.provisioning')} + </label> + + {enabled ? ( + <div className="display-flex-row spacer-top"> + <RadioCard + label={translate( + 'settings.authentication.github.form.provisioning_with_github' + )} + title={translate( + 'settings.authentication.github.form.provisioning_with_github' + )} + selected={newGithubProvisioningStatus ?? githubProvisioningStatus} + onClick={() => setNewGithubProvisioningStatus(true)} + > + <p className="spacer-bottom"> + {translate( + 'settings.authentication.github.form.provisioning_with_github.description' + )} + </p> + <p> + <FormattedMessage + id="settings.authentication.github.form.provisioning_with_github.description.doc" + defaultMessage={translate( + 'settings.authentication.github.form.provisioning_with_github.description.doc' + )} + values={{ + documentation: ( + <DocLink + to={`/instance-administration/authentication/${ + DOCUMENTATION_LINK_SUFFIXES[AlmKeys.GitHub] + }/`} + > + {translate('documentation')} + </DocLink> + ), + }} + /> + </p> + </RadioCard> + <RadioCard + label={translate('settings.authentication.form.provisioning_at_login')} + title={translate('settings.authentication.form.provisioning_at_login')} + selected={!(newGithubProvisioningStatus ?? githubProvisioningStatus)} + onClick={() => setNewGithubProvisioningStatus(false)} + > + {Object.values(values).map((val) => { + if (!GITHUB_JIT_FIELDS.includes(val.key)) { + return null; + } + return ( + <div key={val.key}> + <AuthenticationFormField + settingValue={values[val.key]?.newValue ?? values[val.key]?.value} + definition={val.definition} + mandatory={val.mandatory} + onFieldChange={setNewValue} + isNotSet={val.isNotSet} + /> + </div> + ); + })} + </RadioCard> + </div> + ) : ( + <Alert className="big-spacer-top" variant="info"> + {translate('settings.authentication.github.enable_first')} + </Alert> + )} + </fieldset> + {enabled && ( + <> + <SubmitButton disabled={!hasGithubProvisioningConfigChange}> + {translate('save')} + </SubmitButton> + <ResetButtonLink + className="spacer-left" + onClick={() => { + setNewGithubProvisioningStatus(undefined); + resetJitSetting(); + }} + disabled={!hasGithubProvisioningConfigChange} + > + {translate('cancel')} + </ResetButtonLink> + </> + )} + {showConfirmProvisioningModal && ( + <ConfirmModal + onConfirm={() => handleConfirmChangeProvisioning()} + header={translate( + 'settings.authentication.github.confirm', + newGithubProvisioningStatus ? 'auto' : 'jit' + )} + onClose={() => setShowConfirmProvisioningModal(false)} + isDestructive={!newGithubProvisioningStatus} + confirmButtonText={translate('yes')} + > + {translate( + 'settings.authentication.github.confirm', + newGithubProvisioningStatus ? 'auto' : 'jit', + 'description' + )} + </ConfirmModal> + )} + </form> + </div> + )} </> )} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx index 43ab887e349..d9bc32db7fa 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx @@ -45,7 +45,7 @@ import useSamlConfiguration, { SAML_ENABLED_FIELD, SAML_GROUP_NAME, SAML_SCIM_DEPRECATED, -} from './hook/useLoadSamlSettings'; +} from './hook/useSamlConfiguration'; interface SamlAuthenticationProps { definitions: ExtendedSettingDefinition[]; @@ -116,7 +116,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) { }; return ( - <div className="saml-configuration"> + <div className="authentication-configuration"> <div className="spacer-bottom display-flex-space-between display-flex-center"> <h4>{translate('settings.authentication.saml.configuration')}</h4> @@ -129,7 +129,7 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) { )} </div> {!hasConfiguration && ( - <div className="big-padded text-center huge-spacer-bottom saml-no-config"> + <div className="big-padded text-center huge-spacer-bottom authentication-no-config"> {translate('settings.authentication.saml.form.not_configured')} </div> )} @@ -142,18 +142,18 @@ export default function SamlAuthenticationTab(props: SamlAuthenticationProps) { <p>{url}</p> <p className="big-spacer-top big-spacer-bottom"> {samlEnabled ? ( - <span className="saml-enabled spacer-left"> + <span className="authentication-enabled spacer-left"> <CheckIcon className="spacer-right" /> - {translate('settings.authentication.saml.form.enabled')} + {translate('settings.authentication.form.enabled')} </span> ) : ( - translate('settings.authentication.saml.form.not_enabled') + translate('settings.authentication.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')} + ? translate('settings.authentication.form.disable') + : translate('settings.authentication.form.enable')} </Button> </div> <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 3b7b730add5..dc5235e8cd5 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 @@ -59,12 +59,12 @@ const ui = { 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' }), + enableConfigButton: byRole('button', { name: 'settings.authentication.form.enable' }), + disableConfigButton: byRole('button', { name: 'settings.authentication.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', + name: 'settings.authentication.form.provisioning_at_login', }), scimProvisioningButton: byRole('radio', { name: 'settings.authentication.saml.form.provisioning_with_scim', @@ -92,6 +92,56 @@ const ui = { }); }, }, + github: { + tab: byRole('tab', { name: 'github GitHub' }), + noGithubConfiguration: byText('settings.authentication.github.form.not_configured'), + createConfigButton: byRole('button', { name: 'settings.authentication.form.create' }), + clientId: byRole('textbox', { name: 'Client ID' }), + clientSecret: byRole('textbox', { name: 'Client Secret' }), + githubAppId: byRole('textbox', { name: 'GitHub App ID' }), // not working + privateKey: byRole('textarea', { name: 'Private Key' }), // not working + githubApiUrl: byRole('textbox', { name: 'The API url for a GitHub instance.' }), + githubWebUrl: byRole('textbox', { name: 'The WEB url for a GitHub instance.' }), + allowUserToSignUp: byRole('switch', { + name: 'sonar.auth.github.allowUsersToSignUp', + }), + syncGroupsAsTeams: byRole('switch', { name: 'sonar.auth.github.groupsSync' }), + organizations: byRole('textbox', { name: 'Organizations' }), + saveConfigButton: byRole('button', { name: 'settings.almintegration.form.save' }), + confirmProvisioningButton: byRole('button', { name: 'yes' }), + saveGithubProvisioning: byRole('button', { name: 'save' }), + groupAttribute: byRole('textbox', { name: 'property.sonar.auth.github.group.name.name' }), + enableConfigButton: byRole('button', { name: 'settings.authentication.form.enable' }), + editConfigButton: byRole('button', { name: 'settings.authentication.form.edit' }), + enableFirstMessage: byText('settings.authentication.github.enable_first'), + jitProvisioningButton: byRole('radio', { + name: 'settings.authentication.form.provisioning_at_login', + }), + githubProvisioningButton: byRole('radio', { + name: 'settings.authentication.github.form.provisioning_with_github', + }), + fillForm: async (user: UserEvent) => { + const { github } = ui; + await act(async () => { + await user.type(await github.clientId.find(), 'Awsome GITHUB config'); + await user.type(github.clientSecret.get(), 'Client shut'); + // await user.type(github.githubAppId.get(), 'http://test.org'); + // await user.type(github.privateKey.get(), '-secret-'); + await user.type(github.githubApiUrl.get(), 'API Url'); + await user.type(github.githubWebUrl.get(), 'WEb Url'); + }); + }, + createConfiguration: async (user: UserEvent) => { + const { github } = ui; + await act(async () => { + await user.click((await github.createConfigButton.findAll())[1]); + }); + await github.fillForm(user); + await act(async () => { + await user.click(github.saveConfigButton.get()); + }); + }, + }, }; it('should render tabs and allow navigation', async () => { @@ -204,6 +254,86 @@ describe('SAML tab', () => { }); }); +describe('Github tab', () => { + const { github } = ui; + + it('should render an empty Github configuration', async () => { + renderAuthentication(); + const user = userEvent.setup(); + await user.click(await github.tab.find()); + expect(await github.noGithubConfiguration.find()).toBeInTheDocument(); + }); + + it('should be able to create a configuration', async () => { + const user = userEvent.setup(); + renderAuthentication(); + + await user.click(await github.tab.find()); + await user.click((await github.createConfigButton.findAll())[1]); + + expect(github.saveConfigButton.get()).toBeDisabled(); + + await github.fillForm(user); + expect(github.saveConfigButton.get()).toBeEnabled(); + + await act(async () => { + await user.click(github.saveConfigButton.get()); + }); + + expect(await github.editConfigButton.find()).toBeInTheDocument(); + }); + + it('should be able to enable/disable configuration', async () => { + const { github, saml } = ui; + const user = userEvent.setup(); + renderAuthentication(); + await user.click(await github.tab.find()); + + await github.createConfiguration(user); + + await user.click(await saml.enableConfigButton.find()); + + expect(await saml.disableConfigButton.find()).toBeInTheDocument(); + await user.click(saml.disableConfigButton.get()); + expect(saml.disableConfigButton.query()).not.toBeInTheDocument(); + + expect(await saml.enableConfigButton.find()).toBeInTheDocument(); + }); + + it('should be able to choose provisioning', async () => { + const { github } = ui; + const user = userEvent.setup(); + + renderAuthentication([Feature.GithubProvisioning]); + await user.click(await github.tab.find()); + + await github.createConfiguration(user); + + expect(await github.enableFirstMessage.find()).toBeInTheDocument(); + await user.click(await github.enableConfigButton.find()); + + expect(await github.jitProvisioningButton.find()).toBeChecked(); + + expect(github.saveGithubProvisioning.get()).toBeDisabled(); + await user.click(github.allowUserToSignUp.get()); + await user.click(github.syncGroupsAsTeams.get()); + await user.type(github.organizations.get(), 'organization1, organization2'); + + expect(github.saveGithubProvisioning.get()).toBeEnabled(); + await user.click(github.saveGithubProvisioning.get()); + expect(await github.saveGithubProvisioning.find()).toBeDisabled(); + + await user.click(github.githubProvisioningButton.get()); + + expect(github.saveGithubProvisioning.get()).toBeEnabled(); + await user.click(github.saveGithubProvisioning.get()); + await user.click(github.confirmProvisioningButton.get()); + + expect(await github.githubProvisioningButton.find()).toBeChecked(); + expect(await github.saveGithubProvisioning.find()).toBeDisabled(); + }); +}); + function renderAuthentication(features: Feature[] = []) { renderComponent( <AvailableFeaturesContext.Provider value={features}> 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 index 65596864b49..73eb9d79c9c 100644 --- 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 @@ -18,28 +18,39 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { every, isEmpty, keyBy } from 'lodash'; -import React from 'react'; +import React, { useCallback, useState } 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 type SettingValue = + | { + key: string; + multiValues: false; + mandatory: boolean; + isNotSet: boolean; + value?: string; + newValue?: string | boolean; + definition: ExtendedSettingDefinition; + } + | { + key: string; + multiValues: true; + mandatory: boolean; + isNotSet: boolean; + value?: string[]; + newValue?: string[]; + definition: ExtendedSettingDefinition; + }; export default function useConfiguration( definitions: ExtendedSettingDefinition[], optionalFields: string[] ) { - const [loading, setLoading] = React.useState(true); - const [values, setValues] = React.useState<Dict<SettingValue>>({}); + const [loading, setLoading] = useState(true); + const [values, setValues] = useState<Dict<SettingValue>>({}); - const reload = React.useCallback(async () => { + const reload = useCallback(async () => { const keys = definitions.map((definition) => definition.key); setLoading(true); @@ -51,13 +62,28 @@ export default function useConfiguration( 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, - })), + definitions.map((definition) => { + const value = values.find((v) => v.key === definition.key); + const multiValues = definition.multiValues ?? false; + if (multiValues) { + return { + key: definition.key, + multiValues, + value: value?.values, + mandatory: !optionalFields.includes(definition.key), + isNotSet: value === undefined, + definition, + }; + } + return { + key: definition.key, + multiValues, + value: value?.value, + mandatory: !optionalFields.includes(definition.key), + isNotSet: value === undefined, + definition, + }; + }), 'key' ) ); @@ -72,19 +98,27 @@ export default function useConfiguration( })(); }, [...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 setNewValue = (key: string, newValue?: string | boolean | string[]) => { + const value = values[key]; + if (value.multiValues) { + const newValues = { + ...values, + [key]: { + ...value, + newValue: newValue as string[], + }, + }; + setValues(newValues); + } else { + const newValues = { + ...values, + [key]: { + ...value, + newValue: newValue as string | boolean, + }, + }; + setValues(newValues); + } }; const canBeSave = every( @@ -99,12 +133,12 @@ export default function useConfiguration( (v) => !v.isNotSet ); - const deleteConfiguration = React.useCallback(async () => { + const deleteConfiguration = useCallback(async () => { await resetSettingValue({ keys: Object.keys(values).join(',') }); await reload(); }, [reload, values]); - const isValueChange = React.useCallback( + const isValueChange = useCallback( (setting: string) => { const value = values[setting]; return value && value.newValue !== undefined && (value.value ?? '') !== value.newValue; 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 index 05ec87e7c7d..65d932da6ec 100644 --- 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 @@ -17,20 +17,24 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { some } from 'lodash'; +import { useCallback, useContext, useEffect, useState } from 'react'; +import { fetchIsGithubProvisioningEnabled } from '../../../../../api/settings'; +import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext'; +import { Feature } from '../../../../../types/features'; 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, +export const GITHUB_JIT_FIELDS = [ 'sonar.auth.github.organizations', 'sonar.auth.github.allowUsersToSignUp', 'sonar.auth.github.groupsSync', 'sonar.auth.github.organizations', ]; +export const OPTIONAL_FIELDS = [GITHUB_ENABLED_FIELD, ...GITHUB_JIT_FIELDS]; export interface SamlSettingValue { key: string; @@ -43,12 +47,50 @@ export interface SamlSettingValue { export default function useGithubConfiguration(definitions: ExtendedSettingDefinition[]) { const config = useConfiguration(definitions, OPTIONAL_FIELDS); + const { values, isValueChange, setNewValue, reload: reloadConfig } = config; + const hasGithubProvisioning = useContext(AvailableFeaturesContext).includes( + Feature.GithubProvisioning + ); + const [githubProvisioningStatus, setGithubProvisioningStatus] = useState(false); + const [newGithubProvisioningStatus, setNewGithubProvisioningStatus] = useState<boolean>(); + const hasGithubProvisioningConfigChange = + some(GITHUB_JIT_FIELDS, isValueChange) || + (newGithubProvisioningStatus !== undefined && + newGithubProvisioningStatus !== githubProvisioningStatus); + + const resetJitSetting = () => { + GITHUB_JIT_FIELDS.forEach((s) => setNewValue(s)); + }; - const { values } = config; + useEffect(() => { + (async () => { + if (hasGithubProvisioning) { + setGithubProvisioningStatus(await fetchIsGithubProvisioningEnabled()); + } + })(); + }, [hasGithubProvisioning]); 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 }; + const reload = useCallback(async () => { + await reloadConfig(); + setGithubProvisioningStatus(await fetchIsGithubProvisioningEnabled()); + }, [reloadConfig]); + + return { + ...config, + reload, + url, + enabled, + appId, + hasGithubProvisioning, + setGithubProvisioningStatus, + githubProvisioningStatus, + newGithubProvisioningStatus, + setNewGithubProvisioningStatus, + hasGithubProvisioningConfigChange, + resetJitSetting, + }; } 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/useSamlConfiguration.ts index 7c06147aacf..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/useSamlConfiguration.ts 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 d116f640ea7..a886b08c3fe 100644 --- a/server/sonar-web/src/main/js/apps/settings/styles.css +++ b/server/sonar-web/src/main/js/apps/settings/styles.css @@ -90,12 +90,20 @@ box-sizing: border-box; } +.radio-card .settings-definition-left { + padding-right: 0; +} + .settings-definition-right { position: relative; width: calc(100% - 330px); box-sizing: border-box; } +.radio-card .settings-definition-right input { + width: 100%; +} + .settings-definition-name { text-overflow: ellipsis; } @@ -230,47 +238,47 @@ overflow-wrap: break-word; } -.saml-enabled { +.authentication-enabled { color: var(--success500); } -.saml-no-config { +.authentication-no-config { background-color: var(--neutral50); color: var(--blacka60); } -.saml-configuration .radio-card { +.authentication-configuration .radio-card { width: 50%; background-color: var(--neutral50); border: 1px solid var(--neutral200); } -.saml-configuration .radio-card.selected { +.authentication-configuration .radio-card.selected { background-color: var(--info50); border: 1px solid var(--info500); } -.saml-configuration .radio-card:hover:not(.selected) { +.authentication-configuration .radio-card:hover:not(.selected) { border: 1px solid var(--info500); } -.saml-configuration fieldset > div { +.authentication-configuration fieldset > div { justify-content: space-between; } -.saml-configuration .radio-card-header { +.authentication-configuration .radio-card-header { justify-content: space-around; } -.saml-configuration .radio-card-body { +.authentication-configuration .radio-card-body { justify-content: flex-start; } -.saml-configuration .settings-definition-left { +.authentication-configuration .settings-definition-left { width: 50%; } -.saml-configuration .settings-definition-right { +.authentication-configuration .settings-definition-right { display: flex; align-items: center; width: 50%; diff --git a/server/sonar-web/src/main/js/types/features.ts b/server/sonar-web/src/main/js/types/features.ts index a586efb67a0..fdeb6ab31a5 100644 --- a/server/sonar-web/src/main/js/types/features.ts +++ b/server/sonar-web/src/main/js/types/features.ts @@ -26,4 +26,5 @@ export enum Feature { ProjectImport = 'project-import', RegulatoryReport = 'regulatory-reports', Scim = 'scim', + GithubProvisioning = 'github-provisioning', } |