From cab06a5a89e4f3f085f4e3a7fe5fbcfea4a8e5cc Mon Sep 17 00:00:00 2001 From: Viktor Vorona Date: Thu, 1 Feb 2024 11:47:38 +0100 Subject: [PATCH] SONAR-21507 Show a warning for Bitbucket Authentication in case of insecure config --- .../main/js/api/mocks/SettingsServiceMock.ts | 39 ++- .../apps/settings/components/Definition.tsx | 268 ++++++++++-------- .../settings/components/DefinitionActions.tsx | 2 +- .../components/DefinitionRenderer.tsx | 114 -------- .../components/__tests__/SettingsApp-it.tsx | 4 +- .../authentication/Authentication.tsx | 36 +-- .../AutoProvisionningConsent.tsx | 2 +- .../BitbucketAuthenticationTab.tsx | 89 ++++++ .../__tests__/Authentication-Bitbucket-it.tsx | 105 +++++++ .../authentication/hook/useConfiguration.ts | 2 +- .../sonar-web/src/main/js/queries/settings.ts | 33 ++- .../resources/org/sonar/l10n/core.properties | 3 + 12 files changed, 411 insertions(+), 286 deletions(-) delete mode 100644 server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/BitbucketAuthenticationTab.tsx create mode 100644 server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx diff --git a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts index 7a0c6be4c92..f6d83f8b986 100644 --- a/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SettingsServiceMock.ts @@ -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[] = []; + 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 }); }; diff --git a/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx b/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx index 7cb6a927bba..d8379e27c81 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/Definition.tsx @@ -17,14 +17,27 @@ * 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 { - 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) => e.preventDefault(); +type FieldValue = string | string[] | boolean; + +export default function Definition(props: Readonly) { + const { component, definition, initialSettingValue } = props; + const timeout = React.useRef(); + const [isEditing, setIsEditing] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [success, setSuccess] = React.useState(false); + const [changedValue, setChangedValue] = React.useState(); + const [validationMessage, setValidationMessage] = React.useState(); + 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 { 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 ( - - ); - } + 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 ( +
+ + +
+
+ setIsEditing(true)} + isEditing={isEditing} + isInvalid={hasError} + setting={settingDefinitionAndValue} + value={effectiveValue} + /> + +
+ {loading && ( +
+ + {translate('settings.state.saving')} +
+ )} + + {!loading && validationMessage && ( +
+ +
+ )} + + {!loading && !hasError && success && ( + {translate('settings.state.saved')} + )} +
+ + + +
+
+ ); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx b/server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx index a3d31d5fd0e..158dce175b5 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/DefinitionActions.tsx @@ -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 index bb2a71cfa3e..00000000000 --- a/server/sonar-web/src/main/js/apps/settings/components/DefinitionRenderer.tsx +++ /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) => e.preventDefault(); - -export default function DefinitionRenderer(props: Readonly) { - 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 ( -
- - -
-
- - -
- {loading && ( -
- - {translate('settings.state.saving')} -
- )} - - {!loading && validationMessage && ( -
- -
- )} - - {!loading && !hasError && success && ( - {translate('settings.state.saved')} - )} -
- - - -
-
- ); -} diff --git a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx index d1795c5b788..c666234f5a7 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/__tests__/SettingsApp-it.tsx @@ -45,7 +45,9 @@ afterEach(() => { settingsMock.reset(); }); -beforeEach(jest.clearAllMocks); +beforeEach(() => { + jest.clearAllMocks(); +}); const ui = { categoryLink: (category: string) => byRole('link', { name: category }), diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx index 7e72e9c3721..1363b3287a3 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx @@ -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 && } {tab.value === AlmKeys.BitbucketServer && ( - <> - -
- - {translate('settings.authentication.help.link')} - - ), - }} - /> -
-
- - + )} )} diff --git a/server/sonar-web/src/main/js/apps/settings/components/authentication/AutoProvisionningConsent.tsx b/server/sonar-web/src/main/js/apps/settings/components/authentication/AutoProvisionningConsent.tsx index 04e0c464105..14f5b85ba27 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/authentication/AutoProvisionningConsent.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/AutoProvisionningConsent.tsx @@ -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 index 00000000000..6716e498fc3 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/BitbucketAuthenticationTab.tsx @@ -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) { + 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 && ( + +
+ + {translate('documentation')} + + ), + }} + /> +
+
+ )} + +
+ + {translate('settings.authentication.help.link')} + + ), + }} + /> +
+
+ + + ); +} 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 index 00000000000..8ce6a40fbcf --- /dev/null +++ b/server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-Bitbucket-it.tsx @@ -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( + + + , + `?tab=${AlmKeys.BitbucketServer}`, + ); +} 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 e643ee29038..edd550dc35c 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 @@ -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, 'mutateAsync'>; const isValueChange = useCallback( diff --git a/server/sonar-web/src/main/js/queries/settings.ts b/server/sonar-web/src/main/js/queries/settings.ts index 0ecaa2a37fe..f61420dcbac 100644 --- a/server/sonar-web/src/main/js/queries/settings.ts +++ b/server/sonar-web/src/main/js/queries/settings.ts @@ -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')); }, }); diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index f30ddf6332a..996c5548215 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 -- 2.39.5