瀏覽代碼

SONAR-17295 Improve SAML configuration page user experience

tags/9.7.0.61563
Revanshu Paliwal 1 年之前
父節點
當前提交
e65d9c91e8

+ 76
- 0
server/sonar-web/src/main/js/api/mocks/AuthenticationServiceMock.ts 查看文件

@@ -0,0 +1,76 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { cloneDeep } from 'lodash';
import { mockSettingValue } from '../../helpers/mocks/settings';
import { BranchParameters } from '../../types/branch-like';
import { SettingDefinition, SettingValue } from '../../types/settings';
import { getValues, resetSettingValue, setSettingValue } from '../settings';

export default class AuthenticationServiceMock {
settingValues: SettingValue[];
defaulSettingValues: SettingValue[] = [
mockSettingValue({ key: 'test1', value: '' }),
mockSettingValue({ key: 'test2', value: 'test2' }),
mockSettingValue({ key: 'sonar.auth.saml.certificate.secured' }),
mockSettingValue({ key: 'sonar.auth.saml.enabled', value: 'false' })
];

constructor() {
this.settingValues = cloneDeep(this.defaulSettingValues);
(getValues as jest.Mock).mockImplementation(this.getValuesHandler);
(setSettingValue as jest.Mock).mockImplementation(this.setValueHandler);
(resetSettingValue as jest.Mock).mockImplementation(this.resetValueHandler);
}

getValuesHandler = (data: { keys: string; component?: string } & BranchParameters) => {
if (data.keys) {
return Promise.resolve(
this.settingValues.filter(set => data.keys.split(',').includes(set.key))
);
}
return Promise.resolve(this.settingValues);
};

setValueHandler = (definition: SettingDefinition, value: string) => {
const updatedSettingValue = this.settingValues.find(set => set.key === definition.key);
if (updatedSettingValue) {
updatedSettingValue.value = value;
}
return Promise.resolve();
};

resetValueHandler = (data: { keys: string; component?: string } & BranchParameters) => {
if (data.keys) {
return Promise.resolve(
this.settingValues.map(set => {
if (data.keys.includes(set.key)) {
set.value = '';
}
return set;
})
);
}
return Promise.resolve(this.settingValues);
};

resetValues = () => {
this.settingValues = cloneDeep(this.defaulSettingValues);
};
}

+ 16
- 7
server/sonar-web/src/main/js/apps/settings/components/authentication/Authentication.tsx 查看文件

@@ -31,6 +31,7 @@ import { AlmKeys } from '../../../../types/alm-settings';
import { ExtendedSettingDefinition } from '../../../../types/settings';
import { AUTHENTICATION_CATEGORY } from '../../constants';
import CategoryDefinitionsList from '../CategoryDefinitionsList';
import SamlAuthentication from './SamlAuthentication';

interface Props {
definitions: ExtendedSettingDefinition[];
@@ -134,7 +135,7 @@ export default function Authentication(props: Props) {
role="tabpanel"
aria-labelledby={getTabId(currentTab)}
id={getTabPanelId(currentTab)}>
<div className="big-padded">
<div className="big-padded-top big-padded-left big-padded-right">
<Alert variant="info">
<FormattedMessage
id="settings.authentication.help"
@@ -151,12 +152,20 @@ export default function Authentication(props: Props) {
}}
/>
</Alert>
<CategoryDefinitionsList
category={AUTHENTICATION_CATEGORY}
definitions={definitions}
subCategory={currentTab}
displaySubCategoryTitle={false}
/>
{currentTab === SAML && (
<SamlAuthentication
definitions={definitions.filter(def => def.subCategory === SAML)}
/>
)}

{currentTab !== SAML && (
<CategoryDefinitionsList
category={AUTHENTICATION_CATEGORY}
definitions={definitions}
subCategory={currentTab}
displaySubCategoryTitle={false}
/>
)}
</div>
</div>
)}

+ 266
- 0
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlAuthentication.tsx 查看文件

@@ -0,0 +1,266 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { keyBy } from 'lodash';
import React from 'react';
import { getValues, resetSettingValue, setSettingValue } from '../../../../api/settings';
import { SubmitButton } from '../../../../components/controls/buttons';
import DeferredSpinner from '../../../../components/ui/DeferredSpinner';
import { translate } from '../../../../helpers/l10n';
import { parseError } from '../../../../helpers/request';
import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings';
import SamlFormField from './SamlFormField';
import SamlToggleField from './SamlToggleField';

interface SamlAuthenticationProps {
definitions: ExtendedSettingDefinition[];
}

interface SamlAuthenticationState {
settingValue: Pick<SettingValue, 'key' | 'value'>[];
submitting: boolean;
dirtyFields: string[];
securedFieldsSubmitted: string[];
error: { [key: string]: string };
}

const SAML_ENABLED_FIELD = 'sonar.auth.saml.enabled';

const OPTIONAL_FIELDS = [
'sonar.auth.saml.sp.certificate.secured',
'sonar.auth.saml.sp.privateKey.secured',
'sonar.auth.saml.signature.enabled',
'sonar.auth.saml.user.email',
'sonar.auth.saml.group.name'
];

class SamlAuthentication extends React.PureComponent<
SamlAuthenticationProps,
SamlAuthenticationState
> {
constructor(props: SamlAuthenticationProps) {
super(props);
const settingValue = props.definitions.map(def => {
return {
key: def.key
};
});

this.state = {
settingValue,
submitting: false,
dirtyFields: [],
securedFieldsSubmitted: [],
error: {}
};
}

componentDidMount() {
const { definitions } = this.props;
const keys = definitions.map(definition => definition.key).join(',');
this.loadSettingValues(keys);
}

onFieldChange = (id: string, value: string | boolean) => {
const { settingValue, dirtyFields } = this.state;
const updatedSettingValue = settingValue?.map(set => {
if (set.key === id) {
set.value = String(value);
}
return set;
});

if (!dirtyFields.includes(id)) {
const updatedDirtyFields = [...dirtyFields, id];
this.setState({
dirtyFields: updatedDirtyFields
});
}

this.setState({
settingValue: updatedSettingValue
});
};

async loadSettingValues(keys: string) {
const { settingValue, securedFieldsSubmitted } = this.state;
const values = await getValues({
keys
});
const valuesByDefinitionKey = keyBy(values, 'key');
const updatedSecuredFieldsSubmitted: string[] = [...securedFieldsSubmitted];
const updateSettingValue = settingValue?.map(set => {
if (valuesByDefinitionKey[set.key]) {
set.value =
valuesByDefinitionKey[set.key].value ?? valuesByDefinitionKey[set.key].parentValue;
}

if (
this.isSecuredField(set.key) &&
valuesByDefinitionKey[set.key] &&
!securedFieldsSubmitted.includes(set.key)
) {
updatedSecuredFieldsSubmitted.push(set.key);
}

return set;
});

this.setState({
settingValue: updateSettingValue,
securedFieldsSubmitted: updatedSecuredFieldsSubmitted
});
}

isSecuredField = (key: string) => {
const { definitions } = this.props;
const fieldDefinition = definitions.find(def => def.key === key);
if (fieldDefinition && fieldDefinition.type === SettingType.PASSWORD) {
return true;
}
return false;
};

onSaveConfig = async () => {
const { settingValue, dirtyFields } = this.state;
const { definitions } = this.props;

if (dirtyFields.length === 0) {
return;
}

this.setState({ submitting: true, error: {} });
const promises: Promise<void>[] = [];

settingValue?.forEach(set => {
const definition = definitions.find(def => def.key === set.key);
if (definition && set.value !== undefined && dirtyFields.includes(set.key)) {
const apiCall =
set.value.length > 0
? setSettingValue(definition, set.value)
: resetSettingValue({ keys: definition.key });
const promise = apiCall.catch(async e => {
const { error } = this.state;
const validationMessage = await parseError(e as Response);
this.setState({
submitting: false,
dirtyFields: [],
error: { ...error, ...{ [set.key]: validationMessage } }
});
});
promises.push(promise);
}
});
await Promise.all(promises);
await this.loadSettingValues(dirtyFields.join(','));

this.setState({ submitting: false, dirtyFields: [] });
};

allowEnabling = () => {
const { settingValue, securedFieldsSubmitted } = this.state;
const enabledFlagSettingValue = settingValue.find(set => set.key === SAML_ENABLED_FIELD);
if (enabledFlagSettingValue && enabledFlagSettingValue.value === 'true') {
return true;
}

for (const setting of settingValue) {
const isMandatory = !OPTIONAL_FIELDS.includes(setting.key);
const isSecured = this.isSecuredField(setting.key);
const isSecuredAndNotSubmitted = isSecured && !securedFieldsSubmitted.includes(setting.key);
const isNotSecuredAndNotSubmitted =
!isSecured && (setting.value === '' || setting.value === undefined);
if (isMandatory && (isSecuredAndNotSubmitted || isNotSecuredAndNotSubmitted)) {
return false;
}
}
return true;
};

onEnableFlagChange = (value: boolean) => {
const { settingValue, dirtyFields } = this.state;

const updatedSettingValue = settingValue?.map(set => {
if (set.key === SAML_ENABLED_FIELD) {
set.value = String(value);
}
return set;
});

this.setState(
{
settingValue: updatedSettingValue,
dirtyFields: [...dirtyFields, SAML_ENABLED_FIELD]
},
() => {
this.onSaveConfig();
}
);
};

render() {
const { definitions } = this.props;
const { submitting, settingValue, securedFieldsSubmitted, error, dirtyFields } = this.state;
const enabledFlagDefinition = definitions.find(def => def.key === SAML_ENABLED_FIELD);

return (
<div>
{definitions.map(def => {
if (def.key === SAML_ENABLED_FIELD) {
return null;
}
return (
<SamlFormField
settingValue={settingValue?.find(set => set.key === def.key)}
definition={def}
mandatory={!OPTIONAL_FIELDS.includes(def.key)}
onFieldChange={this.onFieldChange}
showSecuredTextArea={
!securedFieldsSubmitted.includes(def.key) || dirtyFields.includes(def.key)
}
error={error}
key={def.key}
/>
);
})}
<div className="fixed-footer padded-left padded-right">
{enabledFlagDefinition && (
<div>
<label className="h3 spacer-right">{enabledFlagDefinition.name}</label>
<SamlToggleField
definition={enabledFlagDefinition}
settingValue={settingValue?.find(set => set.key === enabledFlagDefinition.key)}
toggleDisabled={!this.allowEnabling()}
onChange={this.onEnableFlagChange}
/>
</div>
)}
<div>
<SubmitButton onClick={this.onSaveConfig}>
{translate('settings.authentication.saml.form.save')}
<DeferredSpinner className="spacer-left" loading={submitting} />
</SubmitButton>
</div>
</div>
</div>
);
}
}

export default SamlAuthentication;

+ 93
- 0
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlFormField.tsx 查看文件

@@ -0,0 +1,93 @@
/*
* SonarQube
* Copyright (C) 2009-2022 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React from 'react';
import ValidationInput, {
ValidationInputErrorPlacement
} from '../../../../components/controls/ValidationInput';
import MandatoryFieldMarker from '../../../../components/ui/MandatoryFieldMarker';
import { ExtendedSettingDefinition, SettingType, SettingValue } from '../../../../types/settings';
import SamlSecuredField from './SamlSecuredField';
import SamlToggleField from './SamlToggleField';

interface SamlToggleFieldProps {
settingValue?: SettingValue;
definition: ExtendedSettingDefinition;
mandatory?: boolean;
onFieldChange: (key: string, value: string | boolean) => void;
showSecuredTextArea?: boolean;
error: { [key: string]: string };
}

const SAML_SIGNATURE_FIELD = 'sonar.auth.saml.signature.enabled';

export default function SamlFormField(props: SamlToggleFieldProps) {
const { mandatory = false, definition, settingValue, showSecuredTextArea = true, error } = props;

return (
<div className="settings-definition" key={definition.key}>
<div className="settings-definition-left">
<label className="h3" htmlFor={definition.key}>
{definition.name}
</label>
{mandatory && <MandatoryFieldMarker />}
{definition.description && (
<div className="markdown small spacer-top">{definition.description}</div>
)}
</div>
<div className="settings-definition-right big-padded-top display-flex-column">
{definition.type === SettingType.PASSWORD && (
<SamlSecuredField
definition={definition}
settingValue={settingValue}
onFieldChange={props.onFieldChange}
showTextArea={showSecuredTextArea}
/>
)}
{definition.type === SettingType.BOOLEAN && (
<SamlToggleField
definition={definition}
settingValue={settingValue}
toggleDisabled={false}
onChange={val => props.onFieldChange(SAML_SIGNATURE_FIELD, val)}
/>
)}
{definition.type === undefined && (
<ValidationInput
error={error[definition.key]}
errorPlacement={ValidationInputErrorPlacement.Bottom}
isValid={false}
isInvalid={Boolean(error[definition.key])}>
<input
className="width-100"
id={definition.key}
maxLength={100}
name={definition.key}
onChange={e => props.onFieldChange(definition.key, e.currentTarget.value)}
size={50}
type="text"
value={settingValue?.value ?? ''}
aria-label={definition.key}
/>
</ValidationInput>
)}
</div>
</div>
);
}

+ 67
- 0
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlSecuredField.tsx 查看文件

@@ -0,0 +1,67 @@
/*
* SonarQube
* Copyright (C) 2009-2022 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React, { useEffect } from 'react';
import { ButtonLink } from '../../../../components/controls/buttons';
import { translate } from '../../../../helpers/l10n';
import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings';

interface SamlToggleFieldProps {
onFieldChange: (key: string, value: string) => void;
settingValue?: SettingValue;
definition: ExtendedSettingDefinition;
optional?: boolean;
showTextArea: boolean;
}

export default function SamlSecuredField(props: SamlToggleFieldProps) {
const { settingValue, definition, optional = true, showTextArea } = props;
const [showField, setShowField] = React.useState(showTextArea);

useEffect(() => {
setShowField(showTextArea);
}, [showTextArea]);

return (
<>
{showField && (
<textarea
className="width-100"
id={definition.key}
maxLength={2000}
onChange={e => props.onFieldChange(definition.key, e.currentTarget.value)}
required={!optional}
rows={5}
value={settingValue?.value ?? ''}
/>
)}
{!showField && (
<div>
<p>{translate('settings.almintegration.form.secret.field')}</p>
<ButtonLink
onClick={() => {
setShowField(true);
}}>
{translate('settings.almintegration.form.secret.update_field')}
</ButtonLink>
</div>
)}
</>
);
}

+ 42
- 0
server/sonar-web/src/main/js/apps/settings/components/authentication/SamlToggleField.tsx 查看文件

@@ -0,0 +1,42 @@
/*
* SonarQube
* Copyright (C) 2009-2022 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import React from 'react';
import Toggle from '../../../../components/controls/Toggle';
import { ExtendedSettingDefinition, SettingValue } from '../../../../types/settings';

interface SamlToggleFieldProps {
toggleDisabled: boolean;
onChange: (value: boolean) => void;
settingValue?: SettingValue;
definition: ExtendedSettingDefinition;
}

export default function SamlToggleField(props: SamlToggleFieldProps) {
const { toggleDisabled, settingValue, definition } = props;

return (
<Toggle
name={definition.key}
onChange={props.onChange}
value={settingValue?.value ?? ''}
disabled={toggleDisabled}
/>
);
}

+ 94
- 3
server/sonar-web/src/main/js/apps/settings/components/authentication/__tests__/Authentication-test.tsx 查看文件

@@ -20,12 +20,25 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock';
import { mockDefinition } from '../../../../../helpers/mocks/settings';
import { renderComponent } from '../../../../../helpers/testReactTestingUtils';
import { ExtendedSettingDefinition, SettingType } from '../../../../../types/settings';
import Authentication from '../Authentication';

jest.mock('../../../../../api/settings');

let handler: AuthenticationServiceMock;

beforeEach(() => {
handler = new AuthenticationServiceMock();
});

afterEach(() => handler.resetValues());

it('should render tabs and allow navigation', async () => {
const user = userEvent.setup();
renderAuthentication();
renderAuthentication([]);

expect(screen.getAllByRole('tab')).toHaveLength(4);

@@ -40,6 +53,84 @@ it('should render tabs and allow navigation', async () => {
);
});

function renderAuthentication() {
renderComponent(<Authentication definitions={[]} />);
it('should allow user to edit fields and save configuration', async () => {
const user = userEvent.setup();
const definitions = [
mockDefinition({
key: 'test1',
category: 'authentication',
subCategory: 'saml',
name: 'test1',
description: 'desc1'
}),
mockDefinition({
key: 'test2',
category: 'authentication',
subCategory: 'saml',
name: 'test2',
description: 'desc2'
}),
mockDefinition({
key: 'sonar.auth.saml.certificate.secured',
category: 'authentication',
subCategory: 'saml',
name: 'Certificate',
description: 'Secured certificate',
type: SettingType.PASSWORD
}),
mockDefinition({
key: 'sonar.auth.saml.enabled',
category: 'authentication',
subCategory: 'saml',
name: 'Enabled',
description: 'To enable the flag',
type: SettingType.BOOLEAN
})
];
renderAuthentication(definitions);

expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'true');
// update fields
await user.click(screen.getByRole('textbox', { name: 'test1' }));
await user.keyboard('new test1');

await user.click(screen.getByRole('textbox', { name: 'test2' }));
await user.keyboard('new test2');
// check if enable is allowed after updating
expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'false');

// reset value
await user.click(screen.getByRole('textbox', { name: 'test2' }));
await user.keyboard('{Control>}a{/Control}{Backspace}');
await user.click(screen.getByRole('button', { name: 'settings.authentication.saml.form.save' }));
expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'true');

await user.click(screen.getByRole('textbox', { name: 'test2' }));
await user.keyboard('new test2');
expect(screen.getByRole('button', { name: 'off' })).toHaveAttribute('aria-disabled', 'false');

expect(
screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' })
).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'settings.almintegration.form.secret.update_field' })
);
// check for secure fields
expect(screen.getByRole('textbox', { name: 'Certificate' })).toBeInTheDocument();
await user.click(screen.getByRole('textbox', { name: 'Certificate' }));
await user.keyboard('new certificate');
// enable the configuration
await user.click(screen.getByRole('button', { name: 'off' }));
expect(screen.getByRole('button', { name: 'on' })).toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'settings.authentication.saml.form.save' }));
// check after switching tab that the flag is still enabled
await user.click(screen.getByRole('tab', { name: 'github GitHub' }));
await user.click(screen.getByRole('tab', { name: 'SAML' }));

expect(screen.getByRole('button', { name: 'on' })).toBeInTheDocument();
});

function renderAuthentication(definitions: ExtendedSettingDefinition[]) {
renderComponent(<Authentication definitions={definitions} />);
}

+ 12
- 0
server/sonar-web/src/main/js/apps/settings/styles.css 查看文件

@@ -199,3 +199,15 @@
overflow-y: auto;
overflow-x: hidden;
}

.fixed-footer {
position: sticky;
bottom: 0px;
height: 50px;
align-items: center;
display: flex;
border: 1px solid var(--gray80);
background-color: white;
justify-content: space-between;
margin: 0px -16px;
}

+ 1
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties 查看文件

@@ -1265,6 +1265,7 @@ settings.authentication.title=Authentication
settings.authentication.description=The following settings allow you to delegate authentication via SAML, or any of the following DevOps Platforms: GitHub, GitLab, and Bitbucket.
settings.authentication.help=If you need help setting up authentication, read our dedicated {link}.
settings.authentication.help.link=documentation
settings.authentication.saml.form.save=Save configuration

settings.pr_decoration.binding.category=DevOps Platform Integration
settings.pr_decoration.binding.no_bindings=A system administrator needs to enable this feature in the global settings.

Loading…
取消
儲存