aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>2023-04-26 17:08:11 +0200
committersonartech <sonartech@sonarsource.com>2023-05-11 20:03:14 +0000
commit07b8350a8821607a55f584bf47ea85a124f9c9a0 (patch)
treeb6d5b166565c1f7440b27ac72a3030a3b21cda8b /server/sonar-web
parent63c1441080c51dbc2e390c3d826b2b8bcf73f10a (diff)
downloadsonarqube-07b8350a8821607a55f584bf47ea85a124f9c9a0.tar.gz
sonarqube-07b8350a8821607a55f584bf47ea85a124f9c9a0.zip
SONAR-19084 GitHub Provisioning in Authentication settings
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts29
-rw-r--r--server/sonar-web/src/main/js/api/settings.ts14
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationFormField.tsx52
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationMultiValuesField.tsx85
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/AuthenticationToggleField.tsx9
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/ConfigurationForm.tsx8
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/GithubAutheticationTab.tsx207
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthenticationTab.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-it.tsx136
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useConfiguration.ts102
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useGithubConfiguration.ts52
-rw-r--r--server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useSamlConfiguration.ts (renamed from server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useLoadSamlSettings.ts)0
-rw-r--r--server/sonar-web/src/main/js/apps/settings/styles.css28
-rw-r--r--server/sonar-web/src/main/js/types/features.ts1
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',
}