diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2019-06-24 11:40:06 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-06-28 08:45:53 +0200 |
commit | 5df0a1d7f1e3c32183cd51c87b58a0bafacc2ab0 (patch) | |
tree | ec309aa3064d8f7c4e7abefdecd4777be072fea7 | |
parent | 2bf160e29360643f447b03c0e9e301f5ab6fb481 (diff) | |
download | sonarqube-5df0a1d7f1e3c32183cd51c87b58a0bafacc2ab0.tar.gz sonarqube-5df0a1d7f1e3c32183cd51c87b58a0bafacc2ab0.zip |
SONAR-11723 Prevent user update if authentication is delegated
4 files changed, 511 insertions, 101 deletions
diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index bf795c87a45..d2c74a215f6 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -22,16 +22,16 @@ import { uniq } from 'lodash'; import UserScmAccountInput from './UserScmAccountInput'; import { createUser, updateUser } from '../../../api/users'; import throwGlobalError from '../../../app/utils/throwGlobalError'; -import Modal from '../../../components/controls/Modal'; +import SimpleModal from '../../../components/controls/SimpleModal'; import { Button, ResetButtonLink, SubmitButton } from '../../../components/ui/buttons'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { parseError } from '../../../helpers/request'; import { Alert } from '../../../components/ui/Alert'; export interface Props { - user?: T.User; onClose: () => void; onUpdateUsers: () => void; + user?: T.User; } interface State { @@ -102,8 +102,7 @@ export default class UserForm extends React.PureComponent<Props, State> { handlePasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => this.setState({ password: event.currentTarget.value }); - handleCreateUser = (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); + handleCreateUser = () => { this.setState({ submitting: true }); createUser({ email: this.state.email || undefined, @@ -117,8 +116,7 @@ export default class UserForm extends React.PureComponent<Props, State> { }, this.handleError); }; - handleUpdateUser = (event: React.SyntheticEvent<HTMLFormElement>) => { - event.preventDefault(); + handleUpdateUser = () => { this.setState({ submitting: true }); updateUser({ email: this.state.email, @@ -149,123 +147,132 @@ export default class UserForm extends React.PureComponent<Props, State> { render() { const { user } = this.props; - const { error, submitting } = this.state; + const { error } = this.state; const header = user ? translate('users.update_user') : translate('users.create_user'); return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> - <form - autoComplete="off" - id="user-form" - onSubmit={this.props.user ? this.handleUpdateUser : this.handleCreateUser}> - <header className="modal-head"> - <h2>{header}</h2> - </header> + <SimpleModal + header={header} + onClose={this.props.onClose} + onSubmit={user ? this.handleUpdateUser : this.handleCreateUser} + size="small"> + {({ onCloseClick, onFormSubmit, submitting }) => ( + <form autoComplete="off" id="user-form" onSubmit={onFormSubmit}> + <header className="modal-head"> + <h2>{header}</h2> + </header> + + <div className="modal-body"> + {error && <Alert variant="error">{error}</Alert>} - <div className="modal-body"> - {error && <Alert variant="error">{error}</Alert>} + {!error && user && !user.local && ( + <Alert variant="warning">{translate('users.cannot_update_delegated_user')}</Alert> + )} - {!user && ( + {!user && ( + <div className="modal-field"> + <label htmlFor="create-user-login"> + {translate('login')} + <em className="mandatory">*</em> + </label> + {/* keep this fake field to hack browser autofill */} + <input className="hidden" name="login-fake" type="text" /> + <input + autoFocus={true} + id="create-user-login" + maxLength={255} + minLength={3} + name="login" + onChange={this.handleLoginChange} + required={true} + type="text" + value={this.state.login} + /> + <p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p> + </div> + )} <div className="modal-field"> - <label htmlFor="create-user-login"> - {translate('login')} + <label htmlFor="create-user-name"> + {translate('name')} <em className="mandatory">*</em> </label> {/* keep this fake field to hack browser autofill */} - <input className="hidden" name="login-fake" type="text" /> + <input className="hidden" name="name-fake" type="text" /> <input - autoFocus={true} - id="create-user-login" - maxLength={255} - minLength={3} - name="login" - onChange={this.handleLoginChange} + autoFocus={!!user} + disabled={user && !user.local} + id="create-user-name" + maxLength={200} + name="name" + onChange={this.handleNameChange} required={true} type="text" - value={this.state.login} + value={this.state.name} /> - <p className="note">{translateWithParameters('users.minimum_x_characters', 3)}</p> </div> - )} - <div className="modal-field"> - <label htmlFor="create-user-name"> - {translate('name')} - <em className="mandatory">*</em> - </label> - {/* keep this fake field to hack browser autofill */} - <input className="hidden" name="name-fake" type="text" /> - <input - autoFocus={!!user} - id="create-user-name" - maxLength={200} - name="name" - onChange={this.handleNameChange} - required={true} - type="text" - value={this.state.name} - /> - </div> - <div className="modal-field"> - <label htmlFor="create-user-email">{translate('users.email')}</label> - {/* keep this fake field to hack browser autofill */} - <input className="hidden" name="email-fake" type="email" /> - <input - id="create-user-email" - maxLength={100} - name="email" - onChange={this.handleEmailChange} - type="email" - value={this.state.email} - /> - </div> - {!user && ( <div className="modal-field"> - <label htmlFor="create-user-password"> - {translate('password')} - <em className="mandatory">*</em> - </label> + <label htmlFor="create-user-email">{translate('users.email')}</label> {/* keep this fake field to hack browser autofill */} - <input className="hidden" name="password-fake" type="password" /> + <input className="hidden" name="email-fake" type="email" /> <input - id="create-user-password" - maxLength={50} - name="password" - onChange={this.handlePasswordChange} - required={true} - type="password" - value={this.state.password} + disabled={user && !user.local} + id="create-user-email" + maxLength={100} + name="email" + onChange={this.handleEmailChange} + type="email" + value={this.state.email} /> </div> - )} - <div className="modal-field"> - <label>{translate('my_profile.scm_accounts')}</label> - {this.state.scmAccounts.map((scm, idx) => ( - <UserScmAccountInput - idx={idx} - key={idx} - onChange={this.handleUpdateScmAccount} - onRemove={this.handleRemoveScmAccount} - scmAccount={scm} - /> - ))} - <div className="spacer-bottom"> - <Button className="js-scm-account-add" onClick={this.handleAddScmAccount}> - {translate('add_verb')} - </Button> + {!user && ( + <div className="modal-field"> + <label htmlFor="create-user-password"> + {translate('password')} + <em className="mandatory">*</em> + </label> + {/* keep this fake field to hack browser autofill */} + <input className="hidden" name="password-fake" type="password" /> + <input + id="create-user-password" + maxLength={50} + name="password" + onChange={this.handlePasswordChange} + required={true} + type="password" + value={this.state.password} + /> + </div> + )} + <div className="modal-field"> + <label>{translate('my_profile.scm_accounts')}</label> + {this.state.scmAccounts.map((scm, idx) => ( + <UserScmAccountInput + idx={idx} + key={idx} + onChange={this.handleUpdateScmAccount} + onRemove={this.handleRemoveScmAccount} + scmAccount={scm} + /> + ))} + <div className="spacer-bottom"> + <Button className="js-scm-account-add" onClick={this.handleAddScmAccount}> + {translate('add_verb')} + </Button> + </div> + <p className="note">{translate('user.login_or_email_used_as_scm_account')}</p> </div> - <p className="note">{translate('user.login_or_email_used_as_scm_account')}</p> </div> - </div> - <footer className="modal-foot"> - {submitting && <i className="spinner spacer-right" />} - <SubmitButton disabled={submitting}> - {user ? translate('update_verb') : translate('create')} - </SubmitButton> - <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink> - </footer> - </form> - </Modal> + <footer className="modal-foot"> + {submitting && <i className="spinner spacer-right" />} + <SubmitButton disabled={submitting}> + {user ? translate('update_verb') : translate('create')} + </SubmitButton> + <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink> + </footer> + </form> + )} + </SimpleModal> ); } } diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx new file mode 100644 index 00000000000..e8b487d50c5 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/UserForm-test.tsx @@ -0,0 +1,110 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import UserForm from '../UserForm'; +import { mockUser } from '../../../../helpers/testMocks'; +import { createUser, updateUser } from '../../../../api/users'; +import { waitAndUpdate, submit } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/users', () => ({ + createUser: jest.fn().mockResolvedValue({}), + updateUser: jest.fn().mockResolvedValue({}) +})); + +it('should render correctly', () => { + expect(shallowRender().dive()).toMatchSnapshot(); + expect(shallowRender({ user: undefined }).dive()).toMatchSnapshot(); +}); + +it('should correctly show errors', async () => { + (updateUser as jest.Mock).mockRejectedValue({ + response: { + status: 400 + } + }); + const wrapper = shallowRender(); + submit(wrapper.dive().find('form')); + await waitAndUpdate(wrapper); + expect( + wrapper + .dive() + .find('Alert') + .dive() + .text() + ).toMatch('default_error_message'); +}); + +it('should correctly disable name and email fields for non-local users', () => { + const wrapper = shallowRender({ user: mockUser({ local: false }) }).dive(); + expect(wrapper.find('#create-user-name').prop('disabled')).toBe(true); + expect(wrapper.find('#create-user-email').prop('disabled')).toBe(true); + expect(wrapper.find('Alert').exists()).toBe(true); + expect( + wrapper + .find('Alert') + .dive() + .text() + ).toMatch('users.cannot_update_delegated_user'); +}); + +it('should correctly create a new user', () => { + const email = 'foo@bar.ch'; + const login = 'foo'; + const name = 'Foo'; + const password = 'bar'; + const scmAccounts = ['gh', 'gh', 'bitbucket']; + const wrapper = shallowRender({ user: undefined }); + + wrapper.setState({ email, login, name, password, scmAccounts }); + + submit(wrapper.dive().find('form')); + + expect(createUser).toBeCalledWith({ + email, + login, + name, + password, + scmAccount: ['gh', 'bitbucket'] + }); +}); + +it('should correctly update a user', () => { + const email = 'foo@bar.ch'; + const login = 'foo'; + const name = 'Foo'; + const scmAccounts = ['gh', 'gh', 'bitbucket']; + const wrapper = shallowRender({ user: mockUser({ email, login, name, scmAccounts }) }).dive(); + + submit(wrapper.find('form')); + + expect(updateUser).toBeCalledWith({ + email, + login, + name, + scmAccount: ['gh', 'bitbucket'] + }); +}); + +function shallowRender(props: Partial<UserForm['props']> = {}) { + return shallow( + <UserForm onClose={jest.fn()} onUpdateUsers={jest.fn()} user={mockUser()} {...props} /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserForm-test.tsx.snap new file mode 100644 index 00000000000..8b21f614675 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/components/__tests__/__snapshots__/UserForm-test.tsx.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<Modal + contentLabel="users.update_user" + onRequestClose={[MockFunction]} + size="small" +> + <form + autoComplete="off" + id="user-form" + onSubmit={[Function]} + > + <header + className="modal-head" + > + <h2> + users.update_user + </h2> + </header> + <div + className="modal-body" + > + <div + className="modal-field" + > + <label + htmlFor="create-user-name" + > + name + <em + className="mandatory" + > + * + </em> + </label> + <input + className="hidden" + name="name-fake" + type="text" + /> + <input + autoFocus={true} + disabled={false} + id="create-user-name" + maxLength={200} + name="name" + onChange={[Function]} + required={true} + type="text" + value="John Doe" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="create-user-email" + > + users.email + </label> + <input + className="hidden" + name="email-fake" + type="email" + /> + <input + disabled={false} + id="create-user-email" + maxLength={100} + name="email" + onChange={[Function]} + type="email" + value="" + /> + </div> + <div + className="modal-field" + > + <label> + my_profile.scm_accounts + </label> + <div + className="spacer-bottom" + > + <Button + className="js-scm-account-add" + onClick={[Function]} + > + add_verb + </Button> + </div> + <p + className="note" + > + user.login_or_email_used_as_scm_account + </p> + </div> + </div> + <footer + className="modal-foot" + > + <SubmitButton + disabled={false} + > + update_verb + </SubmitButton> + <ResetButtonLink + onClick={[Function]} + > + cancel + </ResetButtonLink> + </footer> + </form> +</Modal> +`; + +exports[`should render correctly 2`] = ` +<Modal + contentLabel="users.create_user" + onRequestClose={[MockFunction]} + size="small" +> + <form + autoComplete="off" + id="user-form" + onSubmit={[Function]} + > + <header + className="modal-head" + > + <h2> + users.create_user + </h2> + </header> + <div + className="modal-body" + > + <div + className="modal-field" + > + <label + htmlFor="create-user-login" + > + login + <em + className="mandatory" + > + * + </em> + </label> + <input + className="hidden" + name="login-fake" + type="text" + /> + <input + autoFocus={true} + id="create-user-login" + maxLength={255} + minLength={3} + name="login" + onChange={[Function]} + required={true} + type="text" + value="" + /> + <p + className="note" + > + users.minimum_x_characters.3 + </p> + </div> + <div + className="modal-field" + > + <label + htmlFor="create-user-name" + > + name + <em + className="mandatory" + > + * + </em> + </label> + <input + className="hidden" + name="name-fake" + type="text" + /> + <input + autoFocus={false} + id="create-user-name" + maxLength={200} + name="name" + onChange={[Function]} + required={true} + type="text" + value="" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="create-user-email" + > + users.email + </label> + <input + className="hidden" + name="email-fake" + type="email" + /> + <input + id="create-user-email" + maxLength={100} + name="email" + onChange={[Function]} + type="email" + value="" + /> + </div> + <div + className="modal-field" + > + <label + htmlFor="create-user-password" + > + password + <em + className="mandatory" + > + * + </em> + </label> + <input + className="hidden" + name="password-fake" + type="password" + /> + <input + id="create-user-password" + maxLength={50} + name="password" + onChange={[Function]} + required={true} + type="password" + value="" + /> + </div> + <div + className="modal-field" + > + <label> + my_profile.scm_accounts + </label> + <div + className="spacer-bottom" + > + <Button + className="js-scm-account-add" + onClick={[Function]} + > + add_verb + </Button> + </div> + <p + className="note" + > + user.login_or_email_used_as_scm_account + </p> + </div> + </div> + <footer + className="modal-foot" + > + <SubmitButton + disabled={false} + > + create + </SubmitButton> + <ResetButtonLink + onClick={[Function]} + > + cancel + </ResetButtonLink> + </footer> + </form> +</Modal> +`; 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 52d8534b768..3a940ac84fe 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -3175,6 +3175,7 @@ users.deactivate_user=Deactivate User users.deactivate_user.confirmation=Are you sure you want to deactivate "{0} ({1})"? users.create_user=Create User users.update_user=Update User +users.cannot_update_delegated_user=You cannot update the name and email of this user, as it is controlled by an external identity provider. users.minimum_x_characters=Minimum {0} characters users.email=Email users.last_connection=Last connection |