]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21507 Show a warning for Bitbucket Authentication in case of insecure config
authorViktor Vorona <viktor.vorona@sonarsource.com>
Thu, 1 Feb 2024 10:47:38 +0000 (11:47 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 1 Feb 2024 20:02:48 +0000 (20:02 +0000)
12 files changed:
server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts
server/sonar-web/src/main/js/apps/settings/components/Definition.tsx
server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx
server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx [deleted file]
server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/AutoProvisionningConsent.tsx
server/sonar-web/src/main/js/apps/settings/components/authentication/BitbucketAuthenticationTab.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/settings/components/authentication/hook/useConfiguration.ts
server/sonar-web/src/main/js/queries/settings.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7a0c6be4c92bc9d051ca7a34925cc081ce0502ee..f6d83f8b986f3087fdb6500d78cf901480dc6140 100644 (file)
@@ -29,6 +29,7 @@ import {
   SettingValue,
   SettingsKey,
 } from '../../types/settings';
+import { Dict } from '../../types/types';
 import {
   checkSecretKey,
   encryptValue,
@@ -159,7 +160,16 @@ export default class SettingsServiceMock {
 
   handleGetValue = (data: { key: string; component?: string } & BranchParameters) => {
     const setting = this.#settingValues.find((s) => s.key === data.key) as SettingValue;
-    return this.reply(setting ?? {});
+    const definition = this.#definitions.find(
+      (d) => d.key === data.key,
+    ) as ExtendedSettingDefinition;
+    if (!setting && definition?.defaultValue !== undefined) {
+      const fields = definition.multiValues
+        ? { values: definition.defaultValue?.split(',') }
+        : { value: definition.defaultValue };
+      return this.reply({ key: data.key, ...fields });
+    }
+    return this.reply(setting ?? undefined);
   };
 
   handleGetValues = (data: { keys: string[]; component?: string } & BranchParameters) => {
@@ -215,11 +225,26 @@ export default class SettingsServiceMock {
         (s) => s.key !== 'sonar.auth.github.userConsentForPermissionProvisioningRequired',
       );
     } else if (definition.type === SettingType.PROPERTY_SET) {
-      setting.fieldValues = [];
+      const fieldValues: Dict<string>[] = [];
+      if (setting) {
+        setting.fieldValues = fieldValues;
+      } else {
+        this.#settingValues.push({ key: data.keys, fieldValues });
+      }
     } else if (definition.multiValues === true) {
-      setting.values = definition.defaultValue?.split(',') ?? [];
-    } else if (setting) {
-      setting.value = definition.defaultValue ?? '';
+      const values = definition.defaultValue?.split(',') ?? [];
+      if (setting) {
+        setting.values = values;
+      } else {
+        this.#settingValues.push({ key: data.keys, values });
+      }
+    } else {
+      const value = definition.defaultValue ?? '';
+      if (setting) {
+        setting.value = value;
+      } else {
+        this.#settingValues.push({ key: data.keys, value });
+      }
     }
 
     return this.reply(undefined);
@@ -246,6 +271,10 @@ export default class SettingsServiceMock {
     this.#definitions.push(definition);
   };
 
+  setDefinitions = (definitions: ExtendedSettingDefinition[]) => {
+    this.#definitions = definitions;
+  };
+
   handleCheckSecretKey = () => {
     return this.reply({ secretKeyAvailable: this.#secretKeyAvailable });
   };
index 7cb6a927bba3e4b136701a064ec9b817179a8124..d8379e27c815d30a46307deee38fff576cc02000 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { FlagMessage, Note, Spinner, TextError } from 'design-system';
 import * as React from 'react';
-import { getValue, resetSettingValue, setSettingValue } from '../../../api/settings';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { parseError } from '../../../helpers/request';
+import {
+  useGetValueQuery,
+  useResetSettingsMutation,
+  useSaveValueMutation,
+} from '../../../queries/settings';
 import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../types/settings';
 import { Component } from '../../../types/types';
-import { isEmptyValue, isURLKind } from '../utils';
-import DefinitionRenderer from './DefinitionRenderer';
+import {
+  combineDefinitionAndSettingValue,
+  getSettingValue,
+  isDefaultOrInherited,
+  isEmptyValue,
+  isURLKind,
+} from '../utils';
+import DefinitionActions from './DefinitionActions';
+import DefinitionDescription from './DefinitionDescription';
+import Input from './inputs/Input';
 
 interface Props {
   component?: Component;
@@ -32,88 +45,68 @@ interface Props {
   initialSettingValue?: SettingValue;
 }
 
-interface State {
-  changedValue?: string;
-  isEditing: boolean;
-  loading: boolean;
-  success: boolean;
-  validationMessage?: string;
-  settingValue?: SettingValue;
-}
-
 const SAFE_SET_STATE_DELAY = 3000;
-
-export default class Definition extends React.PureComponent<Props, State> {
-  timeout?: number;
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      isEditing: false,
-      loading: false,
-      success: false,
-      settingValue: props.initialSettingValue,
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-    clearTimeout(this.timeout);
-  }
-
-  handleChange = (changedValue: any) => {
-    clearTimeout(this.timeout);
-
-    this.setState({ changedValue, success: false }, this.handleCheck);
+const formNoop = (e: React.FormEvent<HTMLFormElement>) => e.preventDefault();
+type FieldValue = string | string[] | boolean;
+
+export default function Definition(props: Readonly<Props>) {
+  const { component, definition, initialSettingValue } = props;
+  const timeout = React.useRef<number | undefined>();
+  const [isEditing, setIsEditing] = React.useState(false);
+  const [loading, setLoading] = React.useState(false);
+  const [success, setSuccess] = React.useState(false);
+  const [changedValue, setChangedValue] = React.useState<FieldValue>();
+  const [validationMessage, setValidationMessage] = React.useState<string>();
+  const { data: loadedSettingValue, isLoading } = useGetValueQuery(definition.key, component?.key);
+  const settingValue = isLoading ? initialSettingValue : loadedSettingValue ?? undefined;
+
+  const { mutateAsync: resetSettingValue } = useResetSettingsMutation();
+  const { mutateAsync: saveSettingValue } = useSaveValueMutation();
+
+  React.useEffect(() => () => clearTimeout(timeout.current), []);
+
+  const handleChange = (changedValue: FieldValue) => {
+    clearTimeout(timeout.current);
+
+    setChangedValue(changedValue);
+    setSuccess(false);
+    handleCheck(changedValue);
   };
 
-  handleReset = async () => {
-    const { component, definition } = this.props;
-
-    this.setState({ loading: true, success: false });
+  const handleReset = async () => {
+    setLoading(true);
+    setSuccess(false);
 
     try {
-      await resetSettingValue({ keys: definition.key, component: component?.key });
-      const settingValue = await getValue({ key: definition.key, component: component?.key });
-
-      this.setState({
-        changedValue: undefined,
-        loading: false,
-        success: true,
-        validationMessage: undefined,
-        settingValue,
-      });
-
-      this.timeout = window.setTimeout(() => {
-        this.setState({ success: false });
+      await resetSettingValue({ keys: [definition.key], component: component?.key });
+
+      setChangedValue(undefined);
+      setLoading(false);
+      setSuccess(true);
+      setValidationMessage(undefined);
+
+      timeout.current = window.setTimeout(() => {
+        setSuccess(false);
       }, SAFE_SET_STATE_DELAY);
     } catch (e) {
       const validationMessage = await parseError(e as Response);
-      this.setState({ loading: false, validationMessage });
+      setLoading(false);
+      setValidationMessage(validationMessage);
     }
   };
 
-  handleCancel = () => {
-    this.setState({ changedValue: undefined, validationMessage: undefined, isEditing: false });
+  const handleCancel = () => {
+    setChangedValue(undefined);
+    setValidationMessage(undefined);
+    setIsEditing(false);
   };
 
-  handleCheck = () => {
-    const { definition } = this.props;
-    const { changedValue } = this.state;
-
-    if (isEmptyValue(definition, changedValue)) {
+  const handleCheck = (value?: FieldValue) => {
+    if (isEmptyValue(definition, value)) {
       if (definition.defaultValue === undefined) {
-        this.setState({
-          validationMessage: translate('settings.state.value_cant_be_empty_no_default'),
-        });
+        setValidationMessage(translate('settings.state.value_cant_be_empty_no_default'));
       } else {
-        this.setState({ validationMessage: translate('settings.state.value_cant_be_empty') });
+        setValidationMessage(translate('settings.state.value_cant_be_empty'));
       }
       return false;
     }
@@ -121,85 +114,122 @@ export default class Definition extends React.PureComponent<Props, State> {
     if (isURLKind(definition)) {
       try {
         // eslint-disable-next-line no-new
-        new URL(changedValue ?? '');
+        new URL(value?.toString() ?? '');
       } catch (e) {
-        this.setState({
-          validationMessage: translateWithParameters(
-            'settings.state.url_not_valid',
-            changedValue ?? '',
-          ),
-        });
+        setValidationMessage(
+          translateWithParameters('settings.state.url_not_valid', value?.toString() ?? ''),
+        );
         return false;
       }
     }
 
     if (definition.type === SettingType.JSON) {
       try {
-        JSON.parse(changedValue ?? '');
+        JSON.parse(value?.toString() ?? '');
       } catch (e) {
-        this.setState({ validationMessage: (e as Error).message });
+        setValidationMessage((e as Error).message);
 
         return false;
       }
     }
 
-    this.setState({ validationMessage: undefined });
+    setValidationMessage(undefined);
     return true;
   };
 
-  handleEditing = () => {
-    this.setState({ isEditing: true });
-  };
-
-  handleSave = async () => {
-    const { component, definition } = this.props;
-    const { changedValue } = this.state;
-
+  const handleSave = async () => {
     if (changedValue !== undefined) {
-      this.setState({ success: false });
+      setSuccess(false);
 
       if (isEmptyValue(definition, changedValue)) {
-        this.setState({ validationMessage: translate('settings.state.value_cant_be_empty') });
+        setValidationMessage(translate('settings.state.value_cant_be_empty'));
 
         return;
       }
 
-      this.setState({ loading: true });
+      setLoading(true);
 
       try {
-        await setSettingValue(definition, changedValue, component?.key);
-        const settingValue = await getValue({ key: definition.key, component: component?.key });
-
-        this.setState({
-          changedValue: undefined,
-          isEditing: false,
-          loading: false,
-          success: true,
-          settingValue,
-        });
-
-        this.timeout = window.setTimeout(() => {
-          this.setState({ success: false });
+        await saveSettingValue({ definition, newValue: changedValue, component: component?.key });
+
+        setChangedValue(undefined);
+        setIsEditing(false);
+        setLoading(false);
+        setSuccess(true);
+
+        timeout.current = window.setTimeout(() => {
+          setSuccess(false);
         }, SAFE_SET_STATE_DELAY);
       } catch (e) {
         const validationMessage = await parseError(e as Response);
-        this.setState({ loading: false, validationMessage });
+        setLoading(false);
+        setValidationMessage(validationMessage);
       }
     }
   };
 
-  render() {
-    const { definition } = this.props;
-    return (
-      <DefinitionRenderer
-        definition={definition}
-        onCancel={this.handleCancel}
-        onChange={this.handleChange}
-        onEditing={this.handleEditing}
-        onReset={this.handleReset}
-        onSave={this.handleSave}
-        {...this.state}
-      />
-    );
-  }
+  const hasError = validationMessage != null;
+  const hasValueChanged = changedValue != null;
+  const effectiveValue = hasValueChanged ? changedValue : getSettingValue(definition, settingValue);
+  const isDefault = isDefaultOrInherited(settingValue);
+
+  const settingDefinitionAndValue = combineDefinitionAndSettingValue(definition, settingValue);
+
+  return (
+    <div data-key={definition.key} data-testid={definition.key} className="sw-flex sw-gap-12">
+      <DefinitionDescription definition={definition} />
+
+      <div className="sw-flex-1">
+        <form onSubmit={formNoop}>
+          <Input
+            hasValueChanged={hasValueChanged}
+            onCancel={handleCancel}
+            onChange={handleChange}
+            onSave={handleSave}
+            onEditing={() => setIsEditing(true)}
+            isEditing={isEditing}
+            isInvalid={hasError}
+            setting={settingDefinitionAndValue}
+            value={effectiveValue}
+          />
+
+          <div className="sw-mt-2">
+            {loading && (
+              <div className="sw-flex">
+                <Spinner />
+                <Note className="sw-ml-2">{translate('settings.state.saving')}</Note>
+              </div>
+            )}
+
+            {!loading && validationMessage && (
+              <div>
+                <TextError
+                  text={translateWithParameters(
+                    'settings.state.validation_failed',
+                    validationMessage,
+                  )}
+                />
+              </div>
+            )}
+
+            {!loading && !hasError && success && (
+              <FlagMessage variant="success">{translate('settings.state.saved')}</FlagMessage>
+            )}
+          </div>
+
+          <DefinitionActions
+            changedValue={changedValue}
+            hasError={hasError}
+            hasValueChanged={hasValueChanged}
+            isDefault={isDefault}
+            isEditing={isEditing}
+            onCancel={handleCancel}
+            onReset={handleReset}
+            onSave={handleSave}
+            setting={settingDefinitionAndValue}
+          />
+        </form>
+      </div>
+    </div>
+  );
 }
index a3d31d5fd0e8fbd81ed5088cb78378739cd27960..158dce175b59c022c9ad4811e927704302d7f59f 100644 (file)
@@ -24,7 +24,7 @@ import { Setting } from '../../../types/settings';
 import { getDefaultValue, getPropertyName, isEmptyValue } from '../utils';
 
 type Props = {
-  changedValue?: string;
+  changedValue?: string | string[] | boolean;
   hasError: boolean;
   hasValueChanged: boolean;
   isDefault: boolean;
diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx
deleted file mode 100644 (file)
index bb2a71c..0000000
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { FlagMessage, Note, Spinner, TextError } from 'design-system';
-import * as React from 'react';
-import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { ExtendedSettingDefinition, SettingValue } from '../../../types/settings';
-import { combineDefinitionAndSettingValue, getSettingValue, isDefaultOrInherited } from '../utils';
-import DefinitionActions from './DefinitionActions';
-import DefinitionDescription from './DefinitionDescription';
-import Input from './inputs/Input';
-
-export interface DefinitionRendererProps {
-  definition: ExtendedSettingDefinition;
-  changedValue?: string;
-  loading: boolean;
-  success: boolean;
-  validationMessage?: string;
-  settingValue?: SettingValue;
-  isEditing: boolean;
-  onCancel: () => void;
-  onChange: (value: any) => void;
-  onEditing: () => void;
-  onSave: () => void;
-  onReset: () => void;
-}
-
-const formNoop = (e: React.FormEvent<HTMLFormElement>) => e.preventDefault();
-
-export default function DefinitionRenderer(props: Readonly<DefinitionRendererProps>) {
-  const { changedValue, loading, validationMessage, settingValue, success, definition, isEditing } =
-    props;
-
-  const hasError = validationMessage != null;
-  const hasValueChanged = changedValue != null;
-  const effectiveValue = hasValueChanged ? changedValue : getSettingValue(definition, settingValue);
-  const isDefault = isDefaultOrInherited(settingValue);
-
-  const settingDefinitionAndValue = combineDefinitionAndSettingValue(definition, settingValue);
-
-  return (
-    <div data-key={definition.key} className="sw-flex sw-gap-12">
-      <DefinitionDescription definition={definition} />
-
-      <div className="sw-flex-1">
-        <form onSubmit={formNoop}>
-          <Input
-            hasValueChanged={hasValueChanged}
-            onCancel={props.onCancel}
-            onChange={props.onChange}
-            onSave={props.onSave}
-            onEditing={props.onEditing}
-            isEditing={isEditing}
-            isInvalid={hasError}
-            setting={settingDefinitionAndValue}
-            value={effectiveValue}
-          />
-
-          <div className="sw-mt-2">
-            {loading && (
-              <div className="sw-flex">
-                <Spinner />
-                <Note className="sw-ml-2">{translate('settings.state.saving')}</Note>
-              </div>
-            )}
-
-            {!loading && validationMessage && (
-              <div>
-                <TextError
-                  text={translateWithParameters(
-                    'settings.state.validation_failed',
-                    validationMessage,
-                  )}
-                />
-              </div>
-            )}
-
-            {!loading && !hasError && success && (
-              <FlagMessage variant="success">{translate('settings.state.saved')}</FlagMessage>
-            )}
-          </div>
-
-          <DefinitionActions
-            changedValue={changedValue}
-            hasError={hasError}
-            hasValueChanged={hasValueChanged}
-            isDefault={isDefault}
-            isEditing={isEditing}
-            onCancel={props.onCancel}
-            onReset={props.onReset}
-            onSave={props.onSave}
-            setting={settingDefinitionAndValue}
-          />
-        </form>
-      </div>
-    </div>
-  );
-}
index d1795c5b788ce48668e88d7743e6304352e95436..c666234f5a776502eaf7c9c86d186464a432591e 100644 (file)
@@ -45,7 +45,9 @@ afterEach(() => {
   settingsMock.reset();
 });
 
-beforeEach(jest.clearAllMocks);
+beforeEach(() => {
+  jest.clearAllMocks();
+});
 
 const ui = {
   categoryLink: (category: string) => byRole('link', { name: category }),
index 7e72e9c37211ed15c4f0ff4b154b7134e9bf2735..1363b3287a3441f8932ea3eea3202863e93a33bb 100644 (file)
@@ -25,7 +25,6 @@ import { useSearchParams } from 'react-router-dom';
 import withAvailableFeatures, {
   WithAvailableFeaturesProps,
 } from '../../../../app/components/available-features/withAvailableFeatures';
-import DocumentationLink from '../../../../components/common/DocumentationLink';
 import { getTabId, getTabPanelId } from '../../../../components/controls/BoxedTabs';
 import { translate } from '../../../../helpers/l10n';
 import { getBaseUrl } from '../../../../helpers/system';
@@ -33,8 +32,7 @@ import { searchParamsToQuery } from '../../../../helpers/urls';
 import { AlmKeys } from '../../../../types/alm-settings';
 import { Feature } from '../../../../types/features';
 import { ExtendedSettingDefinition } from '../../../../types/settings';
-import { AUTHENTICATION_CATEGORY } from '../../constants';
-import CategoryDefinitionsList from '../CategoryDefinitionsList';
+import BitbucketAuthenticationTab from './BitbucketAuthenticationTab';
 import GitLabAuthenticationTab from './GitLabAuthenticationTab';
 import GithubAuthenticationTab from './GithubAuthenticationTab';
 import SamlAuthenticationTab, { SAML } from './SamlAuthenticationTab';
@@ -108,10 +106,11 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) {
     },
   ] as const;
 
-  const [samlDefinitions, githubDefinitions] = React.useMemo(
+  const [samlDefinitions, githubDefinitions, bitbucketDefinitions] = React.useMemo(
     () => [
       definitions.filter((def) => def.subCategory === SAML),
       definitions.filter((def) => def.subCategory === AlmKeys.GitHub),
+      definitions.filter((def) => def.subCategory === AlmKeys.BitbucketServer),
     ],
     [definitions],
   );
@@ -171,34 +170,7 @@ export function Authentication(props: Props & WithAvailableFeaturesProps) {
               {tab.value === AlmKeys.GitLab && <GitLabAuthenticationTab />}
 
               {tab.value === AlmKeys.BitbucketServer && (
-                <>
-                  <FlagMessage variant="info">
-                    <div>
-                      <FormattedMessage
-                        id="settings.authentication.help"
-                        defaultMessage={translate('settings.authentication.help')}
-                        values={{
-                          link: (
-                            <DocumentationLink
-                              to={`/instance-administration/authentication/${
-                                DOCUMENTATION_LINK_SUFFIXES[tab.value]
-                              }/`}
-                            >
-                              {translate('settings.authentication.help.link')}
-                            </DocumentationLink>
-                          ),
-                        }}
-                      />
-                    </div>
-                  </FlagMessage>
-                  <CategoryDefinitionsList
-                    category={AUTHENTICATION_CATEGORY}
-                    definitions={definitions}
-                    subCategory={tab.value}
-                    displaySubCategoryTitle={false}
-                    noPadding
-                  />
-                </>
+                <BitbucketAuthenticationTab definitions={bitbucketDefinitions} />
               )}
             </div>
           )}
index 04e0c464105c94298ae082dbb99ee6b0777ecc9e..14f5b85ba2708b99becce674f80041b7c33aef7b 100644 (file)
@@ -37,7 +37,7 @@ export default function AutoProvisioningConsent() {
   const header = translate('settings.authentication.github.confirm_auto_provisioning.header');
 
   const removeConsentFlag = () => {
-    resetSettingsMutation.mutate([GITHUB_PERMISSION_USER_CONSENT]);
+    resetSettingsMutation.mutate({ keys: [GITHUB_PERMISSION_USER_CONSENT] });
   };
 
   const switchToJIT = async () => {
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/BitbucketAuthenticationTab.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/BitbucketAuthenticationTab.tsx
new file mode 100644 (file)
index 0000000..6716e49
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { FlagMessage } from 'design-system';
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import DocumentationLink from '../../../../components/common/DocumentationLink';
+import { translate } from '../../../../helpers/l10n';
+import { useGetValueQuery } from '../../../../queries/settings';
+import { AlmKeys } from '../../../../types/alm-settings';
+import { ExtendedSettingDefinition } from '../../../../types/settings';
+import { AUTHENTICATION_CATEGORY } from '../../constants';
+import CategoryDefinitionsList from '../CategoryDefinitionsList';
+
+interface Props {
+  definitions: ExtendedSettingDefinition[];
+}
+
+export default function BitbucketAuthenticationTab(props: Readonly<Props>) {
+  const { definitions } = props;
+
+  const { data: allowToSignUpEnabled } = useGetValueQuery(
+    'sonar.auth.bitbucket.allowUsersToSignUp',
+  );
+  const { data: workspaces } = useGetValueQuery('sonar.auth.bitbucket.workspaces');
+
+  const isConfigurationUnsafe =
+    allowToSignUpEnabled?.value === 'true' &&
+    (!workspaces?.values || workspaces?.values.length === 0);
+
+  return (
+    <>
+      {isConfigurationUnsafe && (
+        <FlagMessage variant="error" className="sw-mb-2">
+          <div>
+            <FormattedMessage
+              id="settings.authentication.gitlab.configuration.insecure"
+              values={{
+                documentation: (
+                  <DocumentationLink to="/instance-administration/authentication/bitbucket-cloud/#setting-your-authentication-settings-in-sonarqube">
+                    {translate('documentation')}
+                  </DocumentationLink>
+                ),
+              }}
+            />
+          </div>
+        </FlagMessage>
+      )}
+      <FlagMessage variant="info">
+        <div>
+          <FormattedMessage
+            id="settings.authentication.help"
+            defaultMessage={translate('settings.authentication.help')}
+            values={{
+              link: (
+                <DocumentationLink to="/instance-administration/authentication/bitbucket-cloud/">
+                  {translate('settings.authentication.help.link')}
+                </DocumentationLink>
+              ),
+            }}
+          />
+        </div>
+      </FlagMessage>
+      <CategoryDefinitionsList
+        category={AUTHENTICATION_CATEGORY}
+        definitions={definitions}
+        subCategory={AlmKeys.BitbucketServer}
+        displaySubCategoryTitle={false}
+        noPadding
+      />
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx
new file mode 100644 (file)
index 0000000..8ce6a40
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 userEvent from '@testing-library/user-event';
+import React from 'react';
+import SettingsServiceMock from '../../../../../api/mocks/SettingsServiceMock';
+import { AvailableFeaturesContext } from '../../../../../app/components/available-features/AvailableFeaturesContext';
+import { definitions } from '../../../../../helpers/mocks/definitions-list';
+import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
+import { byRole, byTestId, byText } from '../../../../../helpers/testSelector';
+import { AlmKeys } from '../../../../../types/alm-settings';
+import { Feature } from '../../../../../types/features';
+import Authentication from '../Authentication';
+
+let settingsHandler: SettingsServiceMock;
+
+beforeEach(() => {
+  settingsHandler = new SettingsServiceMock();
+  settingsHandler.setDefinitions(definitions);
+});
+
+afterEach(() => {
+  settingsHandler.reset();
+});
+
+const enabledDefinition = byTestId('sonar.auth.bitbucket.enabled');
+const consumerKeyDefinition = byTestId('sonar.auth.bitbucket.clientId.secured');
+const consumerSecretDefinition = byTestId('sonar.auth.bitbucket.clientSecret.secured');
+const allowUsersToSignUpDefinition = byTestId('sonar.auth.bitbucket.allowUsersToSignUp');
+const workspacesDefinition = byTestId('sonar.auth.bitbucket.workspaces');
+
+const ui = {
+  save: byRole('button', { name: 'save' }),
+  cancel: byRole('button', { name: 'cancel' }),
+  reset: byRole('button', { name: /settings.definition.reset/ }),
+  confirmReset: byRole('dialog').byRole('button', { name: 'reset_verb' }),
+  change: byRole('button', { name: 'change_verb' }),
+  enabledDefinition,
+  enabled: enabledDefinition.byRole('switch'),
+  consumerKeyDefinition,
+  consumerKey: consumerKeyDefinition.byRole('textbox'),
+  consumerSecretDefinition,
+  consumerSecret: consumerSecretDefinition.byRole('textbox'),
+  allowUsersToSignUpDefinition,
+  allowUsersToSignUp: allowUsersToSignUpDefinition.byRole('switch'),
+  workspacesDefinition,
+  workspaces: workspacesDefinition.byRole('textbox'),
+  workspacesDelete: workspacesDefinition.byRole('button', {
+    name: /settings.definition.delete_value/,
+  }),
+  insecureWarning: byText(/settings.authentication.gitlab.configuration.insecure/),
+};
+
+it('should show warning if sign up is enabled and there are no workspaces', async () => {
+  renderAuthentication();
+  const user = userEvent.setup();
+
+  expect(await ui.allowUsersToSignUpDefinition.find()).toBeInTheDocument();
+  expect(ui.allowUsersToSignUp.get()).toBeChecked();
+  expect(ui.workspaces.get()).toHaveValue('');
+  expect(ui.insecureWarning.get()).toBeInTheDocument();
+
+  await user.click(ui.allowUsersToSignUp.get());
+  await user.click(ui.allowUsersToSignUpDefinition.by(ui.save).get());
+  expect(ui.allowUsersToSignUp.get()).not.toBeChecked();
+  expect(ui.insecureWarning.query()).not.toBeInTheDocument();
+
+  await user.click(ui.allowUsersToSignUp.get());
+  await user.click(ui.allowUsersToSignUpDefinition.by(ui.save).get());
+  expect(ui.allowUsersToSignUp.get()).toBeChecked();
+  expect(await ui.insecureWarning.find()).toBeInTheDocument();
+
+  await user.type(ui.workspaces.get(), 'test');
+  await user.click(ui.workspacesDefinition.by(ui.save).get());
+  expect(ui.insecureWarning.query()).not.toBeInTheDocument();
+
+  await user.click(ui.workspacesDefinition.by(ui.reset).get());
+  await user.click(ui.confirmReset.get());
+  expect(await ui.insecureWarning.find()).toBeInTheDocument();
+});
+
+function renderAuthentication(features: Feature[] = []) {
+  renderComponent(
+    <AvailableFeaturesContext.Provider value={features}>
+      <Authentication definitions={definitions} />
+    </AvailableFeaturesContext.Provider>,
+    `?tab=${AlmKeys.BitbucketServer}`,
+  );
+}
index e643ee29038135526c5449284f478d48de59f980..edd550dc35cf78a50f468cb0c260ae158e0647da 100644 (file)
@@ -113,7 +113,7 @@ export default function useConfiguration(
   const deleteMutation = update(
     useResetSettingsMutation(),
     'mutate',
-    (mutate) => () => mutate(Object.keys(values)),
+    (mutate) => () => mutate({ keys: Object.keys(values) }),
   ) as Omit<UseMutationResult<void, unknown, void, unknown>, 'mutateAsync'>;
 
   const isValueChange = useCallback(
index 0ecaa2a37fe9b13ef29203c0f241bb6ccafde2b3..f61420dcbac428323810bccb770940d575b19e9f 100644 (file)
@@ -31,18 +31,22 @@ export function useGetValuesQuery(keys: string[]) {
   });
 }
 
-export function useGetValueQuery(key: string) {
+export function useGetValueQuery(key: string, component?: string) {
   return useQuery(['settings', 'details', key] as const, ({ queryKey: [_a, _b, key] }) => {
-    return getValue({ key }).then((v) => v ?? null);
+    return getValue({ key, component }).then((v) => v ?? null);
   });
 }
 
 export function useResetSettingsMutation() {
   const queryClient = useQueryClient();
   return useMutation({
-    mutationFn: (keys: string[]) => resetSettingValue({ keys: keys.join(',') }),
-    onSuccess: () => {
-      queryClient.invalidateQueries(['settings']);
+    mutationFn: ({ keys, component }: { keys: string[]; component?: string }) =>
+      resetSettingValue({ keys: keys.join(','), component }),
+    onSuccess: (_, { keys }) => {
+      keys.forEach((key) => {
+        queryClient.invalidateQueries(['settings', 'details', key]);
+      });
+      queryClient.invalidateQueries(['settings', 'values']);
     },
   });
 }
@@ -75,7 +79,10 @@ export function useSaveValuesMutation() {
     },
     onSuccess: (data) => {
       if (data.length > 0) {
-        queryClient.invalidateQueries(['settings']);
+        data.forEach(({ key }) => {
+          queryClient.invalidateQueries(['settings', 'details', key]);
+        });
+        queryClient.invalidateQueries(['settings', 'values']);
         addGlobalSuccessMessage(translate('settings.authentication.form.settings.save_success'));
       }
     },
@@ -85,21 +92,23 @@ export function useSaveValuesMutation() {
 export function useSaveValueMutation() {
   const queryClient = useQueryClient();
   return useMutation({
-    mutationFn: async ({
+    mutationFn: ({
       newValue,
       definition,
+      component,
     }: {
       newValue: SettingValue;
       definition: ExtendedSettingDefinition;
+      component?: string;
     }) => {
       if (isDefaultValue(newValue, definition)) {
-        await resetSettingValue({ keys: definition.key });
-      } else {
-        await setSettingValue(definition, newValue);
+        return resetSettingValue({ keys: definition.key, component });
       }
+      return setSettingValue(definition, newValue, component);
     },
-    onSuccess: () => {
-      queryClient.invalidateQueries(['settings']);
+    onSuccess: (_, { definition }) => {
+      queryClient.invalidateQueries(['settings', 'details', definition.key]);
+      queryClient.invalidateQueries(['settings', 'values']);
       addGlobalSuccessMessage(translate('settings.authentication.form.settings.save_success'));
     },
   });
index f30ddf6332aaaa764fca47480b58378c5f831f04..996c5548215e47929b3f7f927890de816c3c2c68 100644 (file)
@@ -1616,6 +1616,9 @@ settings.authentication.gitlab.configuration.unsaved_changes=You have unsaved ch
 settings.authentication.gitlab.configuration.valid.JIT=Configuration is valid for Just-in-Time provisioning.
 settings.authentication.gitlab.configuration.valid.AUTO_PROVISIONING=Configuration is valid for Automatic provisioning.
 
+# BITBUCKET
+settings.authentication.gitlab.configuration.insecure=BitBucket Authentication allows users to sign up, but no list of allowed workspaces was provided. This is potentially insecure. We recommend entering a list of allowed workspaces. {documentation}
+
 # COMMON
 settings.authentication.configuration.validity_check_loading=Checking the configuration
 settings.authentication.configuration.test=Test configuration