import { BranchParameters } from '../../types/branch-like';
import { SettingDefinition, SettingValue } from '../../types/settings';
import {
+ activateGithubProvisioning,
activateScim,
+ deactivateGithubProvisioning,
deactivateScim,
+ fetchIsGithubProvisioningEnabled,
fetchIsScimEnabled,
getValues,
resetSettingValue,
export default class AuthenticationServiceMock {
settingValues: SettingValue[];
scimStatus: boolean;
+ githubProvisioningStatus: boolean;
defaulSettingValues: SettingValue[] = [
mockSettingValue({ key: 'test1', value: '' }),
mockSettingValue({ key: 'test2', value: 'test2' }),
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 = () => {
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[]> => {
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);
+}
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;
}
)}
</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}
{!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>
);
--- /dev/null
+/*
+ * 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>
+ );
+}
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 ?? ''}
+ />
+ );
}
};
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>
* 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[];
}
'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,
appId,
enabled,
deleteConfiguration,
+ newGithubProvisioningStatus,
+ setNewGithubProvisioningStatus,
+ hasGithubProvisioningConfigChange,
+ resetJitSetting,
} = useGithubConfiguration(props.definitions);
const handleCreateConfiguration = () => {
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);
};
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>
)}
</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>
) : (
<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>
</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>
+ )}
</>
)}
SAML_ENABLED_FIELD,
SAML_GROUP_NAME,
SAML_SCIM_DEPRECATED,
-} from './hook/useLoadSamlSettings';
+} from './hook/useSamlConfiguration';
interface SamlAuthenticationProps {
definitions: ExtendedSettingDefinition[];
};
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>
)}
</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>
)}
<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>
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',
});
},
},
+ 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 () => {
});
});
+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}>
* 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);
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'
)
);
})();
}, [...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(
(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;
* 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;
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,
+ };
}
+++ /dev/null
-/*
- * 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 { fetchIsScimEnabled } 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 SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled';
-export const SAML_GROUP_NAME = 'sonar.auth.saml.group.name';
-export const SAML_SCIM_DEPRECATED = 'sonar.scim.enabled';
-const SAML_PROVIDER_NAME = 'sonar.auth.saml.providerName';
-const SAML_LOGIN_URL = 'sonar.auth.saml.loginUrl';
-
-const OPTIONAL_FIELDS = [
- 'sonar.auth.saml.sp.certificate.secured',
- 'sonar.auth.saml.sp.privateKey.secured',
- 'sonar.auth.saml.signature.enabled',
- 'sonar.auth.saml.user.email',
- 'sonar.auth.saml.group.name',
- SAML_SCIM_DEPRECATED,
-];
-
-export default function useSamlConfiguration(definitions: ExtendedSettingDefinition[]) {
- const [scimStatus, setScimStatus] = React.useState<boolean>(false);
- 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);
-
- React.useEffect(() => {
- (async () => {
- if (hasScim) {
- setScimStatus(await fetchIsScimEnabled());
- }
- })();
- }, [hasScim]);
-
- const name = values[SAML_PROVIDER_NAME]?.value;
- const url = values[SAML_LOGIN_URL]?.value;
- const samlEnabled = values[SAML_ENABLED_FIELD]?.value === 'true';
- const groupValue = values[SAML_GROUP_NAME];
-
- const setNewGroupSetting = (value?: string) => {
- setNewValue(SAML_GROUP_NAME, value);
- };
-
- const hasScimConfigChange =
- isValueChange(SAML_GROUP_NAME) || (newScimStatus !== undefined && newScimStatus !== scimStatus);
-
- const reload = React.useCallback(async () => {
- await reloadConfig();
- setScimStatus(await fetchIsScimEnabled());
- }, [reloadConfig]);
-
- return {
- hasScim,
- scimStatus,
- loading,
- samlEnabled,
- name,
- url,
- groupValue,
- hasConfiguration,
- canBeSave,
- values,
- setNewValue,
- reload,
- hasScimConfigChange,
- newScimStatus,
- setNewScimStatus,
- setNewGroupSetting,
- deleteConfiguration,
- };
-}
--- /dev/null
+/*
+ * 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 { fetchIsScimEnabled } 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 SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled';
+export const SAML_GROUP_NAME = 'sonar.auth.saml.group.name';
+export const SAML_SCIM_DEPRECATED = 'sonar.scim.enabled';
+const SAML_PROVIDER_NAME = 'sonar.auth.saml.providerName';
+const SAML_LOGIN_URL = 'sonar.auth.saml.loginUrl';
+
+const OPTIONAL_FIELDS = [
+ 'sonar.auth.saml.sp.certificate.secured',
+ 'sonar.auth.saml.sp.privateKey.secured',
+ 'sonar.auth.saml.signature.enabled',
+ 'sonar.auth.saml.user.email',
+ 'sonar.auth.saml.group.name',
+ SAML_SCIM_DEPRECATED,
+];
+
+export default function useSamlConfiguration(definitions: ExtendedSettingDefinition[]) {
+ const [scimStatus, setScimStatus] = React.useState<boolean>(false);
+ 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);
+
+ React.useEffect(() => {
+ (async () => {
+ if (hasScim) {
+ setScimStatus(await fetchIsScimEnabled());
+ }
+ })();
+ }, [hasScim]);
+
+ const name = values[SAML_PROVIDER_NAME]?.value;
+ const url = values[SAML_LOGIN_URL]?.value;
+ const samlEnabled = values[SAML_ENABLED_FIELD]?.value === 'true';
+ const groupValue = values[SAML_GROUP_NAME];
+
+ const setNewGroupSetting = (value?: string) => {
+ setNewValue(SAML_GROUP_NAME, value);
+ };
+
+ const hasScimConfigChange =
+ isValueChange(SAML_GROUP_NAME) || (newScimStatus !== undefined && newScimStatus !== scimStatus);
+
+ const reload = React.useCallback(async () => {
+ await reloadConfig();
+ setScimStatus(await fetchIsScimEnabled());
+ }, [reloadConfig]);
+
+ return {
+ hasScim,
+ scimStatus,
+ loading,
+ samlEnabled,
+ name,
+ url,
+ groupValue,
+ hasConfiguration,
+ canBeSave,
+ values,
+ setNewValue,
+ reload,
+ hasScimConfigChange,
+ newScimStatus,
+ setNewScimStatus,
+ setNewGroupSetting,
+ deleteConfiguration,
+ };
+}
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;
}
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%;
ProjectImport = 'project-import',
RegulatoryReport = 'regulatory-reports',
Scim = 'scim',
+ GithubProvisioning = 'github-provisioning',
}
settings.almintegration.feature.alm_repo_import.disabled.no_url=This feature is disabled because your configured instance has no URL.
settings.almintegration.tabs.authentication_moved=You can delegate authentication to this DevOps Platform. The relevant settings are under the {link} section.
+# Authentication Common
settings.authentication.title=Authentication
settings.authentication.custom_message_information=You can define a custom log-in message to appear on the log-in page to help your users authenticate. The relevant settings are available under the {link} section.
settings.authentication.custom_message_information.link=General
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.enabled=Enabled
+settings.authentication.form.not_enabled=This configuration is disabled
+settings.authentication.form.enable=Enable configuration
+settings.authentication.form.disable=Disable configuration
+settings.authentication.form.provisioning=Provisioning
+settings.authentication.form.provisioning_at_login=Just-in-Time user and group provisioning (default)
+
+# GITHUB
settings.authentication.form.create.github=New Github configuration
settings.authentication.form.edit.github=Edit Github configuration
-
+settings.authentication.github.confirm.auto=Switch to automatic provisioning
+settings.authentication.github.confirm.jit=Switch to Just-in-Time provisioning
+settings.authentication.github.confirm.auto.description=After you switch to automatic provisioning, you will no longer be able to edit groups, users, and group memberships within SonarQube. Are you sure?
+settings.authentication.github.confirm.jit.description=Switching to Just-in-Time provisioning removes all information provided while automatic provisioning through SCIM was active. These changes cannot be reverted. Are you sure?
settings.authentication.github.configuration=Github Configuration
settings.authentication.github.form.not_configured=Github App is not configured
+settings.authentication.github.enable_first=Enable your Github configuration for more provisioning options.
+settings.authentication.github.form.provisioning_with_github=Automatic user and group provisioning
+settings.authentication.github.form.provisioning_with_github.description=Users and groups are automatically provisioned from your GitHub organizations. Once activated, managed users and groups can only be modified from your GitHub organizations/teams. Existing local users and groups will be kept.
+settings.authentication.github.form.provisioning_with_github.description.doc=For more details, see {documentation}.
+
+# SAML
+settings.authentication.form.create.saml=New SAML configuration
+settings.authentication.form.edit.saml=Edit SAML configuration
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
settings.authentication.saml.confirm.jit.description=Switching to Just-in-Time provisioning removes all information provided while automatic provisioning through SCIM was active. These changes cannot be reverted. Are you sure?
settings.authentication.saml.form.loading=Loading SAML configuration
settings.authentication.saml.form.not_configured=SAML is not configured
-settings.authentication.saml.form.enable=Enable configuration
-settings.authentication.saml.form.disable=Disable configuration
-settings.authentication.saml.form.enabled=Enabled
-settings.authentication.saml.form.not_enabled=This configuration is disabled
settings.authentication.saml.form.create=New SAML configuration
settings.authentication.saml.form.edit=Edit SAML configuration
settings.authentication.saml.form.save=Save configuration
settings.authentication.saml.form.test.help.incomplete=Some mandatory fields are empty
settings.authentication.saml.form.save_success=Saved successfully
settings.authentication.saml.form.save_partial=Saved partially
-settings.authentication.saml.form.provisioning=Provisioning
-settings.authentication.saml.form.provisioning_at_login=Just-in-Time user and group provisioning (default)
settings.authentication.saml.form.provisioning_at_login.sub=Use this option if your identity provider does not support the SCIM protocol.
settings.authentication.saml.form.provisioning_with_scim=Automatic user and group provisioning with SCIM
settings.authentication.saml.form.provisioning_with_scim.sub=Preferred option when using a supported identity provider.
settings.authentication.saml.form.provisioning_with_scim.description.doc=For a list of supported providers and more details on automatic provisioning, see {documentation}.
settings.authentication.saml.form.provisioning.disabled=Your current edition does not support provisioning with SCIM. See the {documentation} for more information.
settings.authentication.saml.enable_first=Enable your SAML configuration to benefit from automatic user provisioning options.
+
settings.pr_decoration.binding.category=DevOps Platform Integration
settings.pr_decoration.binding.no_bindings=A system administrator needs to enable this feature in the global settings.
settings.pr_decoration.binding.no_bindings.admin=Set up a {link} first before you and your team can enable Pull Request Decoration.