@@ -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 }); | |||
}; |
@@ -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<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> | |||
); | |||
} |
@@ -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; |
@@ -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> | |||
); | |||
} |
@@ -45,7 +45,9 @@ afterEach(() => { | |||
settingsMock.reset(); | |||
}); | |||
beforeEach(jest.clearAllMocks); | |||
beforeEach(() => { | |||
jest.clearAllMocks(); | |||
}); | |||
const ui = { | |||
categoryLink: (category: string) => byRole('link', { name: category }), |
@@ -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> | |||
)} |
@@ -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 () => { |
@@ -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 | |||
/> | |||
</> | |||
); | |||
} |
@@ -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}`, | |||
); | |||
} |
@@ -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( |
@@ -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')); | |||
}, | |||
}); |
@@ -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 |