From 8c2aedac4d18edb21c07f8a9ab4522185d8a489f Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 26 Jan 2019 13:48:42 -0200 Subject: [PATCH 01/35] Update DynamicForm export --- client/app/components/dynamic-form/DynamicForm.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 329e2493a7..2e47f3868b 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -12,7 +12,7 @@ import { toastr } from '@/services/ng'; import { Field, Action, AntdForm } from '../proptypes'; import helper from './dynamicFormHelper'; -export class DynamicForm extends React.Component { +export const DynamicForm = Form.create()(class DynamicForm extends React.Component { static propTypes = { fields: PropTypes.arrayOf(Field), actions: PropTypes.arrayOf(Action), @@ -202,11 +202,10 @@ export class DynamicForm extends React.Component { ); } -} +}); export default function init(ngModule) { ngModule.component('dynamicForm', react2angular((props) => { - const UpdatedDynamicForm = Form.create()(DynamicForm); const fields = helper.getFields(props.type.configuration_schema, props.target); const onSubmit = (values, onSuccess, onError) => { @@ -231,7 +230,7 @@ export default function init(ngModule) { feedbackIcons: true, onSubmit, }; - return (); + return (); }, ['target', 'type', 'actions'])); } From 16361a4eabcaf5bc519cf078f69a93fa94ef136d Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 26 Jan 2019 13:49:12 -0200 Subject: [PATCH 02/35] Move UserShow to users folder --- client/app/components/{ => users}/UserShow.jsx | 0 client/app/components/{ => users}/UserShow.test.js | 1 - 2 files changed, 1 deletion(-) rename client/app/components/{ => users}/UserShow.jsx (100%) rename client/app/components/{ => users}/UserShow.test.js (99%) diff --git a/client/app/components/UserShow.jsx b/client/app/components/users/UserShow.jsx similarity index 100% rename from client/app/components/UserShow.jsx rename to client/app/components/users/UserShow.jsx diff --git a/client/app/components/UserShow.test.js b/client/app/components/users/UserShow.test.js similarity index 99% rename from client/app/components/UserShow.test.js rename to client/app/components/users/UserShow.test.js index e44f6b6481..59cbd4bfe6 100644 --- a/client/app/components/UserShow.test.js +++ b/client/app/components/users/UserShow.test.js @@ -7,4 +7,3 @@ test('renders correctly', () => { const tree = component.toJSON(); expect(tree).toMatchSnapshot(); }); - From afdceff9b9ce7de2d45446be144ac879e268bb61 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 26 Jan 2019 13:58:43 -0200 Subject: [PATCH 03/35] Migrate User profile header and create DynamicForm for basic data --- client/app/components/proptypes.js | 7 ++++ client/app/components/users/UserEdit.jsx | 47 ++++++++++++++++++++++++ client/app/pages/users/show.html | 28 +------------- client/app/pages/users/show.js | 9 +++++ 4 files changed, 65 insertions(+), 26 deletions(-) create mode 100644 client/app/components/users/UserEdit.jsx diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 19b7f6c5aa..c34ac01260 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -49,3 +49,10 @@ export const Action = PropTypes.shape({ export const AntdForm = PropTypes.shape({ validateFieldsAndScroll: PropTypes.func, }); + +export const UserProfile = PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + email: PropTypes.string, + profileImageUrl: PropTypes.string, +}); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx new file mode 100644 index 0000000000..ce63ed637e --- /dev/null +++ b/client/app/components/users/UserEdit.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { react2angular } from 'react2angular'; +import { UserProfile } from '../proptypes'; +import { DynamicForm } from '../dynamic-form/DynamicForm'; + +export const UserEdit = ({ user }) => { + const formFields = [ + { + name: 'name', + title: 'Name', + type: 'text', + initialValue: user.name, + required: true, + }, + { + name: 'email', + title: 'Email', + type: 'email', + initialValue: user.email, + required: true, + }, + ]; + + return ( +
+ profile +

{user.name}

+
+ +
+ ); +}; + +UserEdit.propTypes = { + user: UserProfile.isRequired, +}; + +export default function init(ngModule) { + ngModule.component('userEdit', react2angular(UserEdit)); +} + +init.init = true; diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index e2419b9579..4f99bcdaa8 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -4,33 +4,9 @@
-
- - - -

{{user.name}}

- -
- -
-
- - - -
-
- - - -
-
- - -
-
+ +

diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index b5bfbd39b5..bea97fba0e 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -41,11 +41,20 @@ function UserCtrl( $scope.selectTab($location.hash() || 'profile'); + $scope.userInfo = {}; + $scope.user = User.get({ id: $scope.userId }, (user) => { if (user.auth_type === 'password') { $scope.showSettings = $scope.canEdit; $scope.showPasswordSettings = $scope.canEdit; } + + $scope.userInfo = { + id: user.id, + name: user.name, + email: user.email, + profileImageUrl: user.profile_image_url, + }; }); $scope.password = { From 8cfb157b241476fae0c6aadb19c08ab67f6329b8 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 26 Jan 2019 14:04:09 -0200 Subject: [PATCH 04/35] Update UserShow to use UserProfile prop --- client/app/components/users/UserShow.jsx | 9 +++------ client/app/components/users/UserShow.test.js | 8 +++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/client/app/components/users/UserShow.jsx b/client/app/components/users/UserShow.jsx index 1e306bfb25..610824508e 100644 --- a/client/app/components/users/UserShow.jsx +++ b/client/app/components/users/UserShow.jsx @@ -1,9 +1,8 @@ import React from 'react'; -import PropTypes from 'prop-types'; - import { react2angular } from 'react2angular'; +import { UserProfile } from '../proptypes'; -export const UserShow = ({ name, email, profileImageUrl }) => ( +export const UserShow = ({ user: { name, email, profileImageUrl } }) => (
profile ( ); UserShow.propTypes = { - name: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - profileImageUrl: PropTypes.string.isRequired, + user: UserProfile.isRequired, }; export default function init(ngModule) { diff --git a/client/app/components/users/UserShow.test.js b/client/app/components/users/UserShow.test.js index 59cbd4bfe6..9170cb8def 100644 --- a/client/app/components/users/UserShow.test.js +++ b/client/app/components/users/UserShow.test.js @@ -3,7 +3,13 @@ import renderer from 'react-test-renderer'; import { UserShow } from './UserShow'; test('renders correctly', () => { - const component = renderer.create(); + const user = { + name: 'John Doe', + email: 'john@doe.com', + profileImageUrl: 'http://www.images.com/llama.jpg', + }; + + const component = renderer.create(); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); }); From c456fb1f94f82431e7dd2eb72a631799db981465 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 27 Jan 2019 16:00:32 -0200 Subject: [PATCH 05/35] Add API Key input --- client/app/components/proptypes.js | 7 +- client/app/components/users/UserEdit.jsx | 97 +++++++++++++++--------- client/app/pages/users/show.html | 4 +- client/app/pages/users/show.js | 3 +- 4 files changed, 68 insertions(+), 43 deletions(-) diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index c34ac01260..98bebf8ccb 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -51,8 +51,9 @@ export const AntdForm = PropTypes.shape({ }); export const UserProfile = PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - email: PropTypes.string, + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, profileImageUrl: PropTypes.string, + apiKey: PropTypes.string, }); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index ce63ed637e..e21c02a751 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -1,44 +1,69 @@ import React from 'react'; +import Icon from 'antd/lib/icon'; +import Input from 'antd/lib/input'; +import Tooltip from 'antd/lib/tooltip'; import { react2angular } from 'react2angular'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; -export const UserEdit = ({ user }) => { - const formFields = [ - { - name: 'name', - title: 'Name', - type: 'text', - initialValue: user.name, - required: true, - }, - { - name: 'email', - title: 'Email', - type: 'email', - initialValue: user.email, - required: true, - }, - ]; - - return ( -
- profile -

{user.name}

-
- -
- ); -}; - -UserEdit.propTypes = { - user: UserProfile.isRequired, -}; +export class UserEdit extends React.Component { + static propTypes = { + user: UserProfile.isRequired, + }; + + constructor(props) { + super(props); + this.state = { apiKey: props.user.apiKey }; + } + + regenerateApiKey = () => { + // TODO + }; + + render() { + const { user } = this.props; + + const formFields = [ + { + name: 'name', + title: 'Name', + type: 'text', + initialValue: user.name, + required: true, + }, + { + name: 'email', + title: 'Email', + type: 'email', + initialValue: user.email, + required: true, + }, + ]; + + const regenerateButton = ( + + + + ); + + return ( +
+ profile +

{user.name}

+
+ +
+ + +
+ ); + } +} export default function init(ngModule) { ngModule.component('userEdit', react2angular(UserEdit)); diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index 4f99bcdaa8..f07c0bb802 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -3,8 +3,8 @@
- - + +

diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index bea97fba0e..4609aff272 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -41,8 +41,6 @@ function UserCtrl( $scope.selectTab($location.hash() || 'profile'); - $scope.userInfo = {}; - $scope.user = User.get({ id: $scope.userId }, (user) => { if (user.auth_type === 'password') { $scope.showSettings = $scope.canEdit; @@ -54,6 +52,7 @@ function UserCtrl( name: user.name, email: user.email, profileImageUrl: user.profile_image_url, + apiKey: user.api_key, }; }); From d4fc964dcc35a3b44e1b03749989f9de3986ee50 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 27 Jan 2019 22:09:56 -0200 Subject: [PATCH 06/35] Add handler to regenerate API Key button --- client/app/components/users/UserEdit.jsx | 18 +++++++++++++- client/app/pages/users/show.html | 11 --------- client/app/pages/users/show.js | 30 +----------------------- client/app/services/user.js | 18 ++++++++++++++ 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index e21c02a751..17416f58f0 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -2,7 +2,9 @@ import React from 'react'; import Icon from 'antd/lib/icon'; import Input from 'antd/lib/input'; import Tooltip from 'antd/lib/tooltip'; +import Modal from 'antd/lib/modal'; import { react2angular } from 'react2angular'; +import { User } from '@/services/user'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; @@ -17,7 +19,21 @@ export class UserEdit extends React.Component { } regenerateApiKey = () => { - // TODO + const doRegenerate = () => { + User.regenerateApiKey(this.props.user).then(({ data }) => { + if (data) { + this.setState({ apiKey: data.api_key }); + } + }); + }; + + Modal.confirm({ + title: 'Regenerate API Key', + content: 'Are you sure you want to regenerate?', + okText: 'Regenerate', + onOk: doRegenerate, + autoFocusButton: null, + }); }; render() { diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index f07c0bb802..acb941244b 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -14,17 +14,6 @@
-
-
- - -
-
- -
- -
-
diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index 4609aff272..806102c1b3 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -6,7 +6,7 @@ import './settings.less'; function UserCtrl( $scope, $routeParams, $http, $location, toastr, - clientConfig, currentUser, User, AlertDialog, + clientConfig, currentUser, User, ) { $scope.userId = $routeParams.userId; $scope.currentUser = currentUser; @@ -130,34 +130,6 @@ function UserCtrl( $scope.disableUser = (user) => { User.disableUser(user); }; - - $scope.regenerateUserApiKey = (user) => { - const doRegenerate = () => { - $scope.disableRegenerateApiKeyButton = true; - $http - .post(`api/users/${$scope.user.id}/regenerate_api_key`) - .success((data) => { - toastr.success('The API Key has been updated.'); - user.api_key = data.api_key; - $scope.disableRegenerateApiKeyButton = false; - }) - .error((response) => { - const message = - response.message - ? response.message - : `Failed regenerating API Key: ${response.statusText}`; - - toastr.error(message); - $scope.disableRegenerateApiKeyButton = false; - }); - }; - - const title = 'Regenerate API Key'; - const message = 'Are you sure you want to regenerate?'; - - AlertDialog.open(title, message, { class: 'btn-warning', title: 'Regenerate' }) - .then(doRegenerate); - }; } export default function init(ngModule) { diff --git a/client/app/services/user.js b/client/app/services/user.js index 9921b7adb8..11765ceb56 100644 --- a/client/app/services/user.js +++ b/client/app/services/user.js @@ -65,6 +65,23 @@ function deleteUser(user) { }); } +function regenerateApiKey(user) { + return $http + .post(`api/users/${user.id}/regenerate_api_key`) + .success((data) => { + toastr.success('The API Key has been updated.'); + return data; + }) + .error((response) => { + const message = + response.message + ? response.message + : `Failed regenerating API Key: ${response.statusText}`; + + toastr.error(message); + }); +} + function UserService($resource) { const actions = { get: { method: 'GET' }, @@ -80,6 +97,7 @@ function UserService($resource) { UserResource.enableUser = enableUser; UserResource.disableUser = disableUser; UserResource.deleteUser = deleteUser; + UserResource.regenerateApiKey = regenerateApiKey; return UserResource; } From 2917e22ed338940ace7a887840dc09a99390bf69 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 27 Jan 2019 22:53:14 -0200 Subject: [PATCH 07/35] Handle user profile save --- client/app/components/users/UserEdit.jsx | 35 ++++++++++++++++++++---- client/app/pages/users/show.js | 20 -------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 17416f58f0..0217ac9338 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -15,14 +15,37 @@ export class UserEdit extends React.Component { constructor(props) { super(props); - this.state = { apiKey: props.user.apiKey }; + this.state = { user: this.props.user }; } + handleSave = (values, onSuccess, onError) => { + const data = { + id: this.props.user.id, + ...values, + }; + + User.save(data, (user) => { + onSuccess('Saved.'); + this.setState({ + user: { + id: user.id, + name: user.name, + email: user.email, + profileImageUrl: user.profile_image_url, + apiKey: user.api_key, + }, + }); + }, (error) => { + onError(error.data.message || 'Failed saving.'); + }); + }; + regenerateApiKey = () => { const doRegenerate = () => { - User.regenerateApiKey(this.props.user).then(({ data }) => { + User.regenerateApiKey(this.state.user).then(({ data }) => { if (data) { - this.setState({ apiKey: data.api_key }); + const { user } = this.state; + this.setState({ user: { ...user, apiKey: data.api_key } }); } }); }; @@ -37,7 +60,7 @@ export class UserEdit extends React.Component { }; render() { - const { user } = this.props; + const { user } = this.state; const formFields = [ { @@ -72,10 +95,10 @@ export class UserEdit extends React.Component { />

{user.name}


- +
- +
); } diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index 806102c1b3..0dc3288fdd 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -86,26 +86,6 @@ function UserCtrl( }); }; - $scope.updateUser = (form) => { - if (!form.$valid) { - return; - } - - const data = { - id: $scope.user.id, - name: $scope.user.name, - email: $scope.user.email, - }; - - User.save(data, (user) => { - toastr.success('Saved.'); - $scope.user = user; - }, (error) => { - const message = error.data.message || 'Failed saving.'; - toastr.error(message); - }); - }; - $scope.isCollapsed = true; $scope.sendPasswordReset = () => { From fff4e7a277df6aadcd2e0fe5e6c0401ecfa1db07 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 28 Jan 2019 10:21:03 -0200 Subject: [PATCH 08/35] Add readOnly prop to DynamicForm and begin disabled user behavior --- .../components/dynamic-form/DynamicForm.jsx | 15 ++++++---- client/app/components/proptypes.js | 1 + client/app/components/users/UserEdit.jsx | 29 +++++++++++++------ client/app/pages/users/show.js | 1 + 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 2e47f3868b..f8c624f3e5 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -17,6 +17,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone fields: PropTypes.arrayOf(Field), actions: PropTypes.arrayOf(Action), feedbackIcons: PropTypes.bool, + readOnly: PropTypes.bool, onSubmit: PropTypes.func, form: AntdForm.isRequired, }; @@ -25,6 +26,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone fields: [], actions: [], feedbackIcons: false, + readOnly: false, onSubmit: () => {}, }; @@ -139,23 +141,25 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone renderFields() { return this.props.fields.map((field) => { - const [firstItem] = this.props.fields; + const [firstField] = this.props.fields; const FormItem = Form.Item; const { name, title, type } = field; const fieldLabel = title || helper.toHuman(name); + const { feedbackIcons, readOnly } = this.props; const formItemProps = { key: name, className: 'm-b-10', - hasFeedback: type !== 'checkbox' && type !== 'file' && this.props.feedbackIcons, + hasFeedback: type !== 'checkbox' && type !== 'file' && feedbackIcons, label: type === 'checkbox' ? '' : fieldLabel, }; const fieldProps = { - autoFocus: (firstItem === field), + autoFocus: (firstField === field), className: 'w-100', name, type, + readOnly, placeholder: field.placeholder, 'data-test': fieldLabel, }; @@ -191,13 +195,12 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone disabled: this.state.isSubmitting, loading: this.state.isSubmitting, }; + const saveButton = !this.props.readOnly; return (
{this.renderFields()} - + {saveButton && ()} {this.renderActions()}
); diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 98bebf8ccb..7d14c3e3f3 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -56,4 +56,5 @@ export const UserProfile = PropTypes.shape({ email: PropTypes.string.isRequired, profileImageUrl: PropTypes.string, apiKey: PropTypes.string, + isDisabled: PropTypes.bool, }); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 0217ac9338..965174f3b9 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -33,6 +33,7 @@ export class UserEdit extends React.Component { email: user.email, profileImageUrl: user.profile_image_url, apiKey: user.api_key, + isDisabled: user.is_disabled, }, }); }, (error) => { @@ -59,6 +60,23 @@ export class UserEdit extends React.Component { }); }; + renderApiKey() { + const { user } = this.state; + + const regenerateButton = ( + + + + ); + + return ( +
+ + +
+ ); + } + render() { const { user } = this.state; @@ -79,12 +97,6 @@ export class UserEdit extends React.Component { }, ]; - const regenerateButton = ( - - - - ); - return (

{user.name}


- +
- - + {!user.isDisabled && this.renderApiKey()}
); } diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index 0dc3288fdd..71c526de9f 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -53,6 +53,7 @@ function UserCtrl( email: user.email, profileImageUrl: user.profile_image_url, apiKey: user.api_key, + isDisabled: user.is_disabled, }; }); From d17266ec1712e81c861b5bf2df7f18a2d85c8b37 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 28 Jan 2019 23:42:10 -0200 Subject: [PATCH 09/35] Add Change Password Modal --- .../components/dynamic-form/DynamicForm.jsx | 7 ++-- client/app/components/users/UserEdit.jsx | 35 +++++++++++++++++-- client/app/pages/users/show.html | 31 ---------------- client/app/pages/users/show.js | 32 ----------------- 4 files changed, 38 insertions(+), 67 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index f8c624f3e5..f3d2eae656 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -18,6 +18,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone actions: PropTypes.arrayOf(Action), feedbackIcons: PropTypes.bool, readOnly: PropTypes.bool, + saveText: PropTypes.string, onSubmit: PropTypes.func, form: AntdForm.isRequired, }; @@ -27,6 +28,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone actions: [], feedbackIcons: false, readOnly: false, + saveText: 'Save', onSubmit: () => {}, }; @@ -195,12 +197,13 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone disabled: this.state.isSubmitting, loading: this.state.isSubmitting, }; - const saveButton = !this.props.readOnly; + const { readOnly, saveText } = this.props; + const saveButton = !readOnly; return (
{this.renderFields()} - {saveButton && ()} + {saveButton && } {this.renderActions()}
); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 965174f3b9..4077c6735e 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -1,10 +1,12 @@ import React from 'react'; +import Button from 'antd/lib/button'; import Icon from 'antd/lib/icon'; import Input from 'antd/lib/input'; import Tooltip from 'antd/lib/tooltip'; import Modal from 'antd/lib/modal'; import { react2angular } from 'react2angular'; import { User } from '@/services/user'; +import { currentUser } from '@/services/auth'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; @@ -15,7 +17,7 @@ export class UserEdit extends React.Component { constructor(props) { super(props); - this.state = { user: this.props.user }; + this.state = { user: this.props.user, changePassword: false }; } handleSave = (values, onSuccess, onError) => { @@ -41,6 +43,30 @@ export class UserEdit extends React.Component { }); }; + openChangePasswordModal = () => { + this.setState({ changePassword: true }); + }; + + changePasswordModal() { + const fields = [ + { name: 'old_password', title: 'Current Password' }, + { name: 'password', title: 'New Password' }, + { name: 'password_repeat', title: 'Repeat New Password' }, + ].map(field => ({ ...field, type: 'password', required: true })); + + return ( + { this.setState({ changePassword: false }); }} + footer={null} + destroyOnClose + > + + + ); + } + regenerateApiKey = () => { const doRegenerate = () => { User.regenerateApiKey(this.state.user).then(({ data }) => { @@ -56,6 +82,7 @@ export class UserEdit extends React.Component { content: 'Are you sure you want to regenerate?', okText: 'Regenerate', onOk: doRegenerate, + maskClosable: true, autoFocusButton: null, }); }; @@ -71,6 +98,7 @@ export class UserEdit extends React.Component { return (
+
@@ -108,8 +136,11 @@ export class UserEdit extends React.Component {

{user.name}


-
+ {this.changePasswordModal()} {!user.isDisabled && this.renderApiKey()} +
+ + {currentUser.isAdmin && }
); } diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index acb941244b..b64db680e7 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -14,38 +14,7 @@
- - -
-
-
-
- - - -
-
- - - -
-
- - - Passwords don't match. -
-
- -
-
-
-
-
-
- {currentUser.isAdmin && } + {!user.isDisabled && ( + + {this.renderApiKey()} +
+ + {currentUser.isAdmin && } +
+ )}
); } From d891df1ad103d9a0171f20f9a644d42133f7aba9 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 30 Jan 2019 22:54:30 -0200 Subject: [PATCH 11/35] Add send password reset behavior --- client/app/assets/less/ant.less | 1 + .../components/dynamic-form/DynamicForm.jsx | 2 +- client/app/components/users/UserEdit.jsx | 70 +++++++++++++++---- client/app/pages/users/show.html | 16 ----- client/app/pages/users/show.js | 12 +--- client/app/services/user.js | 23 ++++-- 6 files changed, 80 insertions(+), 44 deletions(-) diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index c9985fa36d..4673f19e7e 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -1,5 +1,6 @@ @import '~antd/lib/style/core/iconfont'; @import '~antd/lib/style/core/motion'; +@import '~antd/lib/alert/style/index'; @import '~antd/lib/input/style/index'; @import '~antd/lib/input-number/style/index'; @import '~antd/lib/date-picker/style/index'; diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index f3d2eae656..558db28630 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -180,7 +180,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone htmlType: 'button', className: action.pullRight ? 'pull-right m-t-10' : 'm-t-10', type: action.type, - disabled: inProgress || (isFieldsTouched() && action.disableWhenDirty), + disabled: (isFieldsTouched() && action.disableWhenDirty), loading: inProgress, onClick: this.handleAction, }; diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index edcf9ff4d6..9192bc4170 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Alert from 'antd/lib/alert'; import Button from 'antd/lib/button'; import Icon from 'antd/lib/icon'; import Input from 'antd/lib/input'; @@ -6,7 +7,8 @@ import Tooltip from 'antd/lib/tooltip'; import Modal from 'antd/lib/modal'; import { react2angular } from 'react2angular'; import { User } from '@/services/user'; -import { currentUser } from '@/services/auth'; +import { currentUser, clientConfig } from '@/services/auth'; +import { absoluteUrl } from '@/services/utils'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; @@ -17,7 +19,11 @@ export class UserEdit extends React.Component { constructor(props) { super(props); - this.state = { user: this.props.user, changePassword: false }; + this.state = { + user: this.props.user, + changingPassword: false, + sendingPasswordEmail: false, + }; } handleSave = (values, onSuccess, onError) => { @@ -44,7 +50,18 @@ export class UserEdit extends React.Component { }; openChangePasswordModal = () => { - this.setState({ changePassword: true }); + this.setState({ changingPassword: true }); + }; + + sendPasswordReset = () => { + const { user } = this.state; + this.setState({ sendingPasswordEmail: true }); + + User.sendPasswordReset(user).then((passwordResetLink) => { + this.setState({ passwordResetLink }); + }).finally(() => { + this.setState({ sendingPasswordEmail: false }); + }); }; changePasswordModal() { @@ -56,9 +73,9 @@ export class UserEdit extends React.Component { return ( { this.setState({ changePassword: false }); }} + onCancel={() => { this.setState({ changingPassword: false }); }} footer={null} destroyOnClose > @@ -69,11 +86,9 @@ export class UserEdit extends React.Component { regenerateApiKey = () => { const doRegenerate = () => { - User.regenerateApiKey(this.state.user).then(({ data }) => { - if (data) { - const { user } = this.state; - this.setState({ user: { ...user, apiKey: data.api_key } }); - } + User.regenerateApiKey(this.state.user).then((apiKey) => { + const { user } = this.state; + this.setState({ user: { ...user, apiKey } }); }); }; @@ -105,6 +120,37 @@ export class UserEdit extends React.Component { ); } + renderPasswordReset() { + const { user, sendingPasswordEmail, passwordResetLink } = this.state; + + return ( + + + {passwordResetLink && + + The mail server is not configured, please send the following link + to {user.name} to reset their password: + +

+ ) : 'The user should receive a link to reset their password by email soon.'} + type="success" + className="m-t-20" + afterClose={() => { this.setState({ passwordResetLink: null }); }} + closable + /> + } +
+ ); + } + render() { const { user } = this.state; @@ -136,13 +182,13 @@ export class UserEdit extends React.Component {

{user.name}


- {this.changePasswordModal()} {!user.isDisabled && ( {this.renderApiKey()}
+ {this.changePasswordModal()} - {currentUser.isAdmin && } + {currentUser.isAdmin && this.renderPasswordReset()}
)}
diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index b64db680e7..85223c801c 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -7,8 +7,6 @@
-
-
This user is disabled. @@ -16,22 +14,8 @@
-
- -
-

- The user should receive a link to reset their password by email soon. -

-

- You don't have mail server configured, please send the following link - to {{user.name}} to reset their password:
- -

-
diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index e2014c4aa4..538c1a2031 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -1,16 +1,14 @@ import { each } from 'lodash'; import settingsMenu from '@/services/settingsMenu'; -import { absoluteUrl } from '@/services/utils'; import template from './show.html'; import './settings.less'; function UserCtrl( $scope, $routeParams, $http, $location, toastr, - clientConfig, currentUser, User, + currentUser, User, ) { $scope.userId = $routeParams.userId; $scope.currentUser = currentUser; - $scope.clientConfig = clientConfig; if ($scope.userId === undefined) { $scope.userId = currentUser.id; @@ -57,14 +55,6 @@ function UserCtrl( }; }); - $scope.sendPasswordReset = () => { - $scope.disablePasswordResetButton = true; - $http.post(`api/users/${$scope.user.id}/reset_password`).success((data) => { - $scope.disablePasswordResetButton = false; - $scope.passwordResetLink = absoluteUrl(data.reset_link); - }); - }; - $scope.resendInvitation = () => { $http.post(`api/users/${$scope.user.id}/invite`).success(() => { toastr.success('Invitation sent.', { diff --git a/client/app/services/user.js b/client/app/services/user.js index 11765ceb56..0c46877e9b 100644 --- a/client/app/services/user.js +++ b/client/app/services/user.js @@ -68,15 +68,29 @@ function deleteUser(user) { function regenerateApiKey(user) { return $http .post(`api/users/${user.id}/regenerate_api_key`) - .success((data) => { + .then(({ data }) => { toastr.success('The API Key has been updated.'); - return data; + return data.api_key; }) - .error((response) => { + .catch((response) => { + const message = + response.data && response.data.message + ? response.data.message + : `Failed regenerating API Key: ${response.statusText}`; + + toastr.error(message); + }); +} + +function sendPasswordReset(user) { + return $http + .post(`api/users/${user.id}/reset_password`) + .then(({ data }) => data.reset_link) + .catch((response) => { const message = response.message ? response.message - : `Failed regenerating API Key: ${response.statusText}`; + : `Failed to send password reset email: ${response.statusText}`; toastr.error(message); }); @@ -98,6 +112,7 @@ function UserService($resource) { UserResource.disableUser = disableUser; UserResource.deleteUser = deleteUser; UserResource.regenerateApiKey = regenerateApiKey; + UserResource.sendPasswordReset = sendPasswordReset; return UserResource; } From fb1f5714381cace31aefa66e6a3b5f9efac7ec57 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 2 Feb 2019 16:38:54 -0200 Subject: [PATCH 12/35] Add minLength and password comparison to Password Modal --- .../components/dynamic-form/DynamicForm.jsx | 19 +++-- client/app/components/proptypes.js | 1 + client/app/components/users/UserEdit.jsx | 72 +++++++++++-------- 3 files changed, 57 insertions(+), 35 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 2aacd45f6f..6c075894b5 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -12,6 +12,18 @@ import { toastr } from '@/services/ng'; import { Field, Action, AntdForm } from '../proptypes'; import helper from './dynamicFormHelper'; +const fieldRules = ({ title, name, type, required, minLength }) => { + const fieldLabel = title || helper.toHuman(name); + + const requiredRule = required; + const minLengthRule = minLength && ['text', 'email', 'password'].includes(type); + + return [ + requiredRule && { required, message: `${fieldLabel} is required.` }, + minLengthRule && { min: minLength, message: `${fieldLabel} is too short.` }, + ].filter(rule => rule); +}; + export const DynamicForm = Form.create()(class DynamicForm extends React.Component { static propTypes = { fields: PropTypes.arrayOf(Field), @@ -100,11 +112,10 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone renderUpload(field, props) { const { getFieldDecorator, getFieldValue } = this.props.form; - const { name, initialValue, required } = field; - const fieldLabel = field.title || helper.toHuman(name); + const { name, initialValue } = field; const fileOptions = { - rules: [{ required, message: `${fieldLabel} is required.` }], + rules: fieldRules(field), initialValue, getValueFromEvent: this.base64File.bind(this, name), }; @@ -126,7 +137,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone const fieldLabel = field.title || helper.toHuman(name); const options = { - rules: [{ required: field.required, message: `${fieldLabel} is required.` }], + rules: fieldRules(field), valuePropName: type === 'checkbox' ? 'checked' : 'value', initialValue, }; diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 7d14c3e3f3..83b0df853c 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -35,6 +35,7 @@ export const Field = PropTypes.shape({ type: PropTypes.oneOf(['text', 'email', 'password', 'number', 'checkbox', 'file']).isRequired, initialValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), required: PropTypes.bool, + minLength: PropTypes.number, placeholder: PropTypes.string, }); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 9192bc4170..c038ea77dc 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import Alert from 'antd/lib/alert'; import Button from 'antd/lib/button'; +import Form from 'antd/lib/form'; import Icon from 'antd/lib/icon'; import Input from 'antd/lib/input'; import Tooltip from 'antd/lib/tooltip'; @@ -49,6 +50,14 @@ export class UserEdit extends React.Component { }); }; + handleUpdatePassword = (values, onSuccess, onError) => { + if (values.password === values.password_repeat) { + this.handleSave(values, onSuccess, onError); + } else { + onError('Passwords don\'t match!'); + } + } + openChangePasswordModal = () => { this.setState({ changingPassword: true }); }; @@ -64,26 +73,6 @@ export class UserEdit extends React.Component { }); }; - changePasswordModal() { - const fields = [ - { name: 'old_password', title: 'Current Password' }, - { name: 'password', title: 'New Password' }, - { name: 'password_repeat', title: 'Repeat New Password' }, - ].map(field => ({ ...field, type: 'password', required: true })); - - return ( - { this.setState({ changingPassword: false }); }} - footer={null} - destroyOnClose - > - - - ); - } - regenerateApiKey = () => { const doRegenerate = () => { User.regenerateApiKey(this.state.user).then((apiKey) => { @@ -102,6 +91,26 @@ export class UserEdit extends React.Component { }); }; + changePasswordModal() { + const fields = [ + { name: 'old_password', title: 'Current Password' }, + { name: 'password', title: 'New Password', minLength: 6 }, + { name: 'password_repeat', title: 'Repeat New Password' }, + ].map(field => ({ ...field, type: 'password', required: true })); + + return ( + { this.setState({ changingPassword: false }); }} + footer={null} + destroyOnClose + > + + + ); + } + renderApiKey() { const { user } = this.state; @@ -112,11 +121,12 @@ export class UserEdit extends React.Component { ); return ( -
+

- - -
+ + + + ); } @@ -124,7 +134,7 @@ export class UserEdit extends React.Component { const { user, sendingPasswordEmail, passwordResetLink } = this.state; return ( - + - {passwordResetLink && + {passwordResetLink && ( @@ -146,8 +156,8 @@ export class UserEdit extends React.Component { afterClose={() => { this.setState({ passwordResetLink: null }); }} closable /> - } - + )} + ); } @@ -183,13 +193,13 @@ export class UserEdit extends React.Component {
{!user.isDisabled && ( - + {this.renderApiKey()}
{this.changePasswordModal()} {currentUser.isAdmin && this.renderPasswordReset()} -
+ )}
); From 7c6f11e53f86ef03e763d37dbb38e68b4773b9da Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 2 Feb 2019 17:59:01 -0200 Subject: [PATCH 13/35] Resend Invitation button --- client/app/components/users/UserEdit.jsx | 43 ++++++++++++++++++------ client/app/pages/users/show.html | 7 ---- client/app/pages/users/show.js | 9 +---- client/app/services/user.js | 17 ++++++++++ 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index c038ea77dc..b3063ebc74 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -24,6 +24,7 @@ export class UserEdit extends React.Component { user: this.props.user, changingPassword: false, sendingPasswordEmail: false, + resendingInvitation: false, }; } @@ -43,6 +44,7 @@ export class UserEdit extends React.Component { profileImageUrl: user.profile_image_url, apiKey: user.api_key, isDisabled: user.is_disabled, + isInvitationPending: user.is_invitation_pending, }, }); }, (error) => { @@ -73,6 +75,15 @@ export class UserEdit extends React.Component { }); }; + resendInvitation = () => { + const { user } = this.state; + this.setState({ resendingInvitation: true }); + + User.resendInvitation(user).finally(() => { + this.setState({ resendingInvitation: false }); + }); + }; + regenerateApiKey = () => { const doRegenerate = () => { User.regenerateApiKey(this.state.user).then((apiKey) => { @@ -130,24 +141,34 @@ export class UserEdit extends React.Component { ); } - renderPasswordReset() { - const { user, sendingPasswordEmail, passwordResetLink } = this.state; + renderPasswordOptions() { + const { sendingPasswordEmail, passwordResetLink, resendingInvitation } = this.state; return ( - + {this.state.user.isInvitationPending ? ( + + ) : ( + + )} {passwordResetLink && ( The mail server is not configured, please send the following link - to {user.name} to reset their password: + to {this.state.user.name} to reset their password:

) : 'The user should receive a link to reset their password by email soon.'} @@ -198,7 +219,7 @@ export class UserEdit extends React.Component {
{this.changePasswordModal()} - {currentUser.isAdmin && this.renderPasswordReset()} + {currentUser.isAdmin && this.renderPasswordOptions()}
)}
diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index 85223c801c..a807867bc0 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -11,13 +11,6 @@ This user is disabled.
- -
-
- -
-
-
diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index f5e37e1c09..072cfb32db 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -52,17 +52,10 @@ function UserCtrl( profileImageUrl: user.profile_image_url, apiKey: user.api_key, isDisabled: user.is_disabled, + isInvitationPending: user.is_invitation_pending, }; }); - $scope.resendInvitation = () => { - $http.post(`api/users/${$scope.user.id}/invite`).success(() => { - toastr.success('Invitation sent.', { - timeOut: 10000, - }); - }); - }; - $scope.enableUser = (user) => { User.enableUser(user); }; diff --git a/client/app/services/user.js b/client/app/services/user.js index 201b568b2b..91e64817c4 100644 --- a/client/app/services/user.js +++ b/client/app/services/user.js @@ -96,6 +96,22 @@ function sendPasswordReset(user) { }); } +function resendInvitation(user) { + return $http + .post(`api/users/${user.id}/invite`) + .then(() => { + toastr.success('Invitation sent.'); + }) + .catch((response) => { + const message = + response.message + ? response.message + : `Failed to resend invitation: ${response.statusText}`; + + toastr.error(message); + }); +} + function UserService($resource) { const actions = { get: { method: 'GET' }, @@ -113,6 +129,7 @@ function UserService($resource) { UserResource.deleteUser = deleteUser; UserResource.regenerateApiKey = regenerateApiKey; UserResource.sendPasswordReset = sendPasswordReset; + UserResource.resendInvitation = resendInvitation; return UserResource; } From 82237e7e55769d9e96acb0d6a920f704a8b8131b Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 02:06:40 -0200 Subject: [PATCH 14/35] Add Convert User Info --- client/app/components/users/UserEdit.jsx | 10 +--------- client/app/pages/users/show.js | 10 +--------- client/app/services/user.js | 13 +++++++++++++ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index b3063ebc74..b3cb3f8df3 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -37,15 +37,7 @@ export class UserEdit extends React.Component { User.save(data, (user) => { onSuccess('Saved.'); this.setState({ - user: { - id: user.id, - name: user.name, - email: user.email, - profileImageUrl: user.profile_image_url, - apiKey: user.api_key, - isDisabled: user.is_disabled, - isInvitationPending: user.is_invitation_pending, - }, + user: User.convertUserInfo(user), }); }, (error) => { onError(error.data.message || 'Failed saving.'); diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index 072cfb32db..1e90b515e6 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -45,15 +45,7 @@ function UserCtrl( $scope.showPasswordSettings = $scope.canEdit; } - $scope.userInfo = { - id: user.id, - name: user.name, - email: user.email, - profileImageUrl: user.profile_image_url, - apiKey: user.api_key, - isDisabled: user.is_disabled, - isInvitationPending: user.is_invitation_pending, - }; + $scope.userInfo = User.convertUserInfo(user); }); $scope.enableUser = (user) => { diff --git a/client/app/services/user.js b/client/app/services/user.js index 91e64817c4..e6d44e6b26 100644 --- a/client/app/services/user.js +++ b/client/app/services/user.js @@ -65,6 +65,18 @@ function deleteUser(user) { }); } +function convertUserInfo(user) { + return { + id: user.id, + name: user.name, + email: user.email, + profileImageUrl: user.profile_image_url, + apiKey: user.api_key, + isDisabled: user.is_disabled, + isInvitationPending: user.is_invitation_pending, + }; +} + function regenerateApiKey(user) { return $http .post(`api/users/${user.id}/regenerate_api_key`) @@ -127,6 +139,7 @@ function UserService($resource) { UserResource.enableUser = enableUser; UserResource.disableUser = disableUser; UserResource.deleteUser = deleteUser; + UserResource.convertUserInfo = convertUserInfo; UserResource.regenerateApiKey = regenerateApiKey; UserResource.sendPasswordReset = sendPasswordReset; UserResource.resendInvitation = resendInvitation; From f85668bdcc12f21d7f3538684959ff6ed04e3e5b Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 12:19:44 -0200 Subject: [PATCH 15/35] Fix UserShow test --- client/app/components/users/UserShow.test.js | 1 + .../components/{ => users}/__snapshots__/UserShow.test.js.snap | 0 2 files changed, 1 insertion(+) rename client/app/components/{ => users}/__snapshots__/UserShow.test.js.snap (100%) diff --git a/client/app/components/users/UserShow.test.js b/client/app/components/users/UserShow.test.js index 9170cb8def..f2ad155514 100644 --- a/client/app/components/users/UserShow.test.js +++ b/client/app/components/users/UserShow.test.js @@ -4,6 +4,7 @@ import { UserShow } from './UserShow'; test('renders correctly', () => { const user = { + id: 2, name: 'John Doe', email: 'john@doe.com', profileImageUrl: 'http://www.images.com/llama.jpg', diff --git a/client/app/components/__snapshots__/UserShow.test.js.snap b/client/app/components/users/__snapshots__/UserShow.test.js.snap similarity index 100% rename from client/app/components/__snapshots__/UserShow.test.js.snap rename to client/app/components/users/__snapshots__/UserShow.test.js.snap From f5c4090cb5f4d281c7e8d7f3ddd3e5d2d29083b0 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 14:30:54 -0200 Subject: [PATCH 16/35] Some code updates --- client/app/components/users/UserEdit.jsx | 81 ++++++++++++------------ 1 file changed, 42 insertions(+), 39 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index b3cb3f8df3..9a2cd36b3d 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -28,35 +28,33 @@ export class UserEdit extends React.Component { }; } - handleSave = (values, onSuccess, onError) => { + onSaveUser = (values, successCallback, errorCallback) => { const data = { id: this.props.user.id, ...values, }; User.save(data, (user) => { - onSuccess('Saved.'); - this.setState({ - user: User.convertUserInfo(user), - }); + successCallback('Saved.'); + this.setState({ user: User.convertUserInfo(user) }); }, (error) => { - onError(error.data.message || 'Failed saving.'); + errorCallback(error.data.message || 'Failed saving.'); }); }; - handleUpdatePassword = (values, onSuccess, onError) => { + onUpdatePassword = (values, successCallback, errorCallback) => { if (values.password === values.password_repeat) { - this.handleSave(values, onSuccess, onError); + this.onSaveUser(values, successCallback, errorCallback); } else { - onError('Passwords don\'t match!'); + errorCallback('Passwords don\'t match!'); } } - openChangePasswordModal = () => { + onClickChangePassword = () => { this.setState({ changingPassword: true }); }; - sendPasswordReset = () => { + onClickSendPasswordReset = () => { const { user } = this.state; this.setState({ sendingPasswordEmail: true }); @@ -67,7 +65,7 @@ export class UserEdit extends React.Component { }); }; - resendInvitation = () => { + onClickResendInvitation = () => { const { user } = this.state; this.setState({ resendingInvitation: true }); @@ -76,11 +74,10 @@ export class UserEdit extends React.Component { }); }; - regenerateApiKey = () => { + onClickRegenerateApiKey = () => { const doRegenerate = () => { User.regenerateApiKey(this.state.user).then((apiKey) => { - const { user } = this.state; - this.setState({ user: { ...user, apiKey } }); + this.setState(prevState => ({ user: { ...prevState.user, apiKey } })); }); }; @@ -94,7 +91,7 @@ export class UserEdit extends React.Component { }); }; - changePasswordModal() { + renderChangePasswordModal() { const fields = [ { name: 'old_password', title: 'Current Password' }, { name: 'password', title: 'New Password', minLength: 6 }, @@ -109,7 +106,7 @@ export class UserEdit extends React.Component { footer={null} destroyOnClose > - + ); } @@ -119,7 +116,7 @@ export class UserEdit extends React.Component { const regenerateButton = ( - + ); @@ -133,6 +130,26 @@ export class UserEdit extends React.Component { ); } + renderPasswordLinkAlert() { + const { user, passwordResetLink } = this.state; + + return ( + + The mail server is not configured, please send the following link + to {user.name} to reset their password: + +

+ ) : 'The user should receive a link to reset their password by email soon.'} + type="success" + className="m-t-20" + afterClose={() => { this.setState({ passwordResetLink: null }); }} + closable + /> + ); + } + renderPasswordOptions() { const { sendingPasswordEmail, passwordResetLink, resendingInvitation } = this.state; @@ -141,7 +158,7 @@ export class UserEdit extends React.Component { {this.state.user.isInvitationPending ? ( )} - {passwordResetLink && ( - - The mail server is not configured, please send the following link - to {this.state.user.name} to reset their password: - -

- ) : 'The user should receive a link to reset their password by email soon.'} - type="success" - className="m-t-20" - afterClose={() => { this.setState({ passwordResetLink: null }); }} - closable - /> - )} + {passwordResetLink && this.renderPasswordLinkAlert()} ); } @@ -197,20 +200,20 @@ export class UserEdit extends React.Component { return (
profile

{user.name}


- + {!user.isDisabled && ( {this.renderApiKey()}
- {this.changePasswordModal()} - + {this.renderChangePasswordModal()} + {currentUser.isAdmin && this.renderPasswordOptions()}
)} From 6cf8b2dfacddba78d570aec870869a138283ad6a Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 15:19:43 -0200 Subject: [PATCH 17/35] Add enable/disable user button --- client/app/components/users/UserEdit.jsx | 33 +++++++++++++++++++++--- client/app/pages/users/show.html | 7 ----- client/app/pages/users/show.js | 16 ------------ 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 9a2cd36b3d..7c74babc0d 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -55,10 +55,9 @@ export class UserEdit extends React.Component { }; onClickSendPasswordReset = () => { - const { user } = this.state; this.setState({ sendingPasswordEmail: true }); - User.sendPasswordReset(user).then((passwordResetLink) => { + User.sendPasswordReset(this.state.user).then((passwordResetLink) => { this.setState({ passwordResetLink }); }).finally(() => { this.setState({ sendingPasswordEmail: false }); @@ -66,10 +65,9 @@ export class UserEdit extends React.Component { }; onClickResendInvitation = () => { - const { user } = this.state; this.setState({ resendingInvitation: true }); - User.resendInvitation(user).finally(() => { + User.resendInvitation(this.state.user).finally(() => { this.setState({ resendingInvitation: false }); }); }; @@ -91,6 +89,18 @@ export class UserEdit extends React.Component { }); }; + onClickToggleUser = () => { + const { user } = this.state; + const toggleUser = user.isDisabled ? User.enableUser : User.disableUser; + + this.setState({ togglingUser: true }); + toggleUser(user).then(({ data }) => { + this.setState({ user: User.convertUserInfo(data) }); + }).finally(() => { + this.setState({ togglingUser: false }); + }); + }; + renderChangePasswordModal() { const fields = [ { name: 'old_password', title: 'Current Password' }, @@ -177,6 +187,20 @@ export class UserEdit extends React.Component { ); } + renderToggleUser() { + const { user, togglingUser } = this.state; + + return user.isDisabled ? ( + + ) : ( + + ); + } + render() { const { user } = this.state; @@ -217,6 +241,7 @@ export class UserEdit extends React.Component { {currentUser.isAdmin && this.renderPasswordOptions()} )} + {currentUser.isAdmin && this.renderToggleUser()}
); } diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index a807867bc0..5c5d2b9ad2 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -5,13 +5,6 @@
- -
-
- This user is disabled. - -
-
diff --git a/client/app/pages/users/show.js b/client/app/pages/users/show.js index 1e90b515e6..bb5fff9d96 100644 --- a/client/app/pages/users/show.js +++ b/client/app/pages/users/show.js @@ -14,10 +14,6 @@ function UserCtrl( $scope.userId = currentUser.id; } - $scope.canEdit = currentUser.hasPermission('admin') || currentUser.id === parseInt($scope.userId, 10); - $scope.showSettings = false; - $scope.showPasswordSettings = false; - $scope.selectTab = (tab) => { $scope.selectedTab = tab; each($scope.tabs, (v, k) => { @@ -40,20 +36,8 @@ function UserCtrl( $scope.selectTab($location.hash() || 'profile'); $scope.user = User.get({ id: $scope.userId }, (user) => { - if (user.auth_type === 'password') { - $scope.showSettings = $scope.canEdit; - $scope.showPasswordSettings = $scope.canEdit; - } - $scope.userInfo = User.convertUserInfo(user); }); - - $scope.enableUser = (user) => { - User.enableUser(user); - }; - $scope.disableUser = (user) => { - User.disableUser(user); - }; } export default function init(ngModule) { From 8b7336bdf68646c54e47269baa2d16748b0995e7 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 16:39:14 -0200 Subject: [PATCH 18/35] Add UserPolicy as an idea --- client/app/components/users/UserEdit.jsx | 129 +++++++++++++---------- client/app/services/policy/UserPolicy.js | 33 ++++++ client/app/services/policy/index.js | 2 + 3 files changed, 106 insertions(+), 58 deletions(-) create mode 100644 client/app/services/policy/UserPolicy.js diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 7c74babc0d..d2fd4008ca 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -8,7 +8,8 @@ import Tooltip from 'antd/lib/tooltip'; import Modal from 'antd/lib/modal'; import { react2angular } from 'react2angular'; import { User } from '@/services/user'; -import { currentUser, clientConfig } from '@/services/auth'; +import { userPolicy } from '@/services/policy'; +import { clientConfig } from '@/services/auth'; import { absoluteUrl } from '@/services/utils'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; @@ -101,7 +102,35 @@ export class UserEdit extends React.Component { }); }; - renderChangePasswordModal() { + renderBasicInfoForm() { + const { user } = this.state; + const formFields = [ + { + name: 'name', + title: 'Name', + type: 'text', + initialValue: user.name, + required: true, + }, + { + name: 'email', + title: 'Email', + type: 'email', + initialValue: user.email, + required: true, + }, + ]; + + return ( + + ); + } + + renderChangePassword() { const fields = [ { name: 'old_password', title: 'Current Password' }, { name: 'password', title: 'New Password', minLength: 6 }, @@ -109,15 +138,19 @@ export class UserEdit extends React.Component { ].map(field => ({ ...field, type: 'password', required: true })); return ( - { this.setState({ changingPassword: false }); }} - footer={null} - destroyOnClose - > - - + +
+ { this.setState({ changingPassword: false }); }} + footer={null} + destroyOnClose + > + + + +
); } @@ -160,28 +193,30 @@ export class UserEdit extends React.Component { ); } - renderPasswordOptions() { - const { sendingPasswordEmail, passwordResetLink, resendingInvitation } = this.state; + renderResendInvitation() { + return ( + + ); + } + + renderSendPasswordReset() { + const { sendingPasswordEmail, passwordResetLink } = this.state; return ( - {this.state.user.isInvitationPending ? ( - - ) : ( - - )} + {passwordResetLink && this.renderPasswordLinkAlert()} ); @@ -204,23 +239,6 @@ export class UserEdit extends React.Component { render() { const { user } = this.state; - const formFields = [ - { - name: 'name', - title: 'Name', - type: 'text', - initialValue: user.name, - required: true, - }, - { - name: 'email', - title: 'Email', - type: 'email', - initialValue: user.email, - required: true, - }, - ]; - return (

{user.name}


- - {!user.isDisabled && ( - - {this.renderApiKey()} -
- {this.renderChangePasswordModal()} - - {currentUser.isAdmin && this.renderPasswordOptions()} -
- )} - {currentUser.isAdmin && this.renderToggleUser()} + {this.renderBasicInfoForm()} + {userPolicy.canViewApiKey(user) && this.renderApiKey()} + {userPolicy.canChangePassword(user) && this.renderChangePassword()} + {userPolicy.canResendInvitation(user) && this.renderResendInvitation()} + {userPolicy.canSendPasswordResetEmail(user) && this.renderSendPasswordReset()} + {userPolicy.canToggleUser(user) && this.renderToggleUser()}
); } diff --git a/client/app/services/policy/UserPolicy.js b/client/app/services/policy/UserPolicy.js new file mode 100644 index 0000000000..9a22a70f64 --- /dev/null +++ b/client/app/services/policy/UserPolicy.js @@ -0,0 +1,33 @@ +import { currentUser } from '@/services/auth'; + +/* eslint-disable class-methods-use-this */ + +export default class UserPolicy { + canEditUser(user) { + return user && (user.id === currentUser.id || currentUser.isAdmin); + } + + canEditBasicInfo(user) { + return this.canEditUser(user) && !user.isDisabled; + } + + canViewApiKey(user) { + return this.canEditUser(user) && !user.isDisabled; + } + + canChangePassword(user) { + return this.canEditUser(user) && !user.isDisabled; + } + + canResendInvitation(user) { + return user && currentUser.isAdmin && user.isInvitationPending; + } + + canSendPasswordResetEmail(user) { + return user && currentUser.isAdmin && !user.isInvitationPending; + } + + canToggleUser(user) { + return user && currentUser.isAdmin; + } +} diff --git a/client/app/services/policy/index.js b/client/app/services/policy/index.js index 9a4ebd0b1d..d5c16a2b72 100644 --- a/client/app/services/policy/index.js +++ b/client/app/services/policy/index.js @@ -1,7 +1,9 @@ import DefaultPolicy from './DefaultPolicy'; +import UserPolicy from './UserPolicy'; // eslint-disable-next-line import/no-mutable-exports export let policy = new DefaultPolicy(); +export const userPolicy = new UserPolicy(); export function setPolicy(newPolicy) { policy = newPolicy; From ddd97587111e11cc520d7a2da941ea55e3894ae5 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 3 Feb 2019 19:36:17 -0200 Subject: [PATCH 19/35] Remove UserPolicy --- client/app/components/users/UserEdit.jsx | 21 +++++++++------ client/app/services/policy/UserPolicy.js | 33 ------------------------ client/app/services/policy/index.js | 2 -- 3 files changed, 13 insertions(+), 43 deletions(-) delete mode 100644 client/app/services/policy/UserPolicy.js diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index d2fd4008ca..d5b13933e6 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -8,8 +8,7 @@ import Tooltip from 'antd/lib/tooltip'; import Modal from 'antd/lib/modal'; import { react2angular } from 'react2angular'; import { User } from '@/services/user'; -import { userPolicy } from '@/services/policy'; -import { clientConfig } from '@/services/auth'; +import { currentUser, clientConfig } from '@/services/auth'; import { absoluteUrl } from '@/services/utils'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; @@ -124,7 +123,7 @@ export class UserEdit extends React.Component { return ( ); @@ -250,11 +249,17 @@ export class UserEdit extends React.Component {

{user.name}


{this.renderBasicInfoForm()} - {userPolicy.canViewApiKey(user) && this.renderApiKey()} - {userPolicy.canChangePassword(user) && this.renderChangePassword()} - {userPolicy.canResendInvitation(user) && this.renderResendInvitation()} - {userPolicy.canSendPasswordResetEmail(user) && this.renderSendPasswordReset()} - {userPolicy.canToggleUser(user) && this.renderToggleUser()} + {!user.isDisabled && ( + + {this.renderApiKey()} + {this.renderChangePassword()} + {currentUser.isAdmin && ( + user.isInvitationPending ? + this.renderResendInvitation() : this.renderSendPasswordReset() + )} + + )} + {currentUser.isAdmin && this.renderToggleUser()} ); } diff --git a/client/app/services/policy/UserPolicy.js b/client/app/services/policy/UserPolicy.js deleted file mode 100644 index 9a22a70f64..0000000000 --- a/client/app/services/policy/UserPolicy.js +++ /dev/null @@ -1,33 +0,0 @@ -import { currentUser } from '@/services/auth'; - -/* eslint-disable class-methods-use-this */ - -export default class UserPolicy { - canEditUser(user) { - return user && (user.id === currentUser.id || currentUser.isAdmin); - } - - canEditBasicInfo(user) { - return this.canEditUser(user) && !user.isDisabled; - } - - canViewApiKey(user) { - return this.canEditUser(user) && !user.isDisabled; - } - - canChangePassword(user) { - return this.canEditUser(user) && !user.isDisabled; - } - - canResendInvitation(user) { - return user && currentUser.isAdmin && user.isInvitationPending; - } - - canSendPasswordResetEmail(user) { - return user && currentUser.isAdmin && !user.isInvitationPending; - } - - canToggleUser(user) { - return user && currentUser.isAdmin; - } -} diff --git a/client/app/services/policy/index.js b/client/app/services/policy/index.js index d5c16a2b72..9a4ebd0b1d 100644 --- a/client/app/services/policy/index.js +++ b/client/app/services/policy/index.js @@ -1,9 +1,7 @@ import DefaultPolicy from './DefaultPolicy'; -import UserPolicy from './UserPolicy'; // eslint-disable-next-line import/no-mutable-exports export let policy = new DefaultPolicy(); -export const userPolicy = new UserPolicy(); export function setPolicy(newPolicy) { policy = newPolicy; From f9984e08c10241a038de3eb2634a52594c74c6d7 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 4 Feb 2019 00:45:53 -0200 Subject: [PATCH 20/35] Create Edit Profile spec --- .circleci/config.yml | 2 -- client/app/components/users/UserEdit.jsx | 9 +++++++-- cypress/integration/percy/page_screenshots.js | 14 ++++++++++++-- cypress/integration/user/edit_profile_spec.js | 17 +++++++++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 cypress/integration/user/edit_profile_spec.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 1242d7cf18..ac7e5bb0d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -73,8 +73,6 @@ jobs: command: | npm run cypress start docker-compose run cypress node ./cypress/cypress.js db-seed - # Make sure the API key is the same so Percy snapshots are consistent - docker-compose -p cypress run postgres psql -U postgres -h postgres -c "update users set api_key = 'secret' where email ='admin@redash.io';" - run: name: Execute Cypress tests command: npm run cypress run-ci diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index d5b13933e6..02a488f708 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -158,7 +158,12 @@ export class UserEdit extends React.Component { const regenerateButton = ( - + ); @@ -166,7 +171,7 @@ export class UserEdit extends React.Component {

- +
); diff --git a/cypress/integration/percy/page_screenshots.js b/cypress/integration/percy/page_screenshots.js index 8170944007..a887b04fdc 100644 --- a/cypress/integration/percy/page_screenshots.js +++ b/cypress/integration/percy/page_screenshots.js @@ -6,16 +6,26 @@ const pages = [ { name: 'Group', url: '/groups/1' }, { name: 'Create Destination - Types', url: '/destinations/new' }, { name: 'Organization Settings', url: '/settings/organization' }, - { name: 'User Profile', url: '/users/me' }, ]; describe('Percy Page Screenshots', () => { + beforeEach(() => { + cy.login(); + }); + pages.forEach((page) => { it(`takes a screenshot of ${page.name}`, () => { - cy.login(); cy.visit(page.url); cy.wait(1000); cy.percySnapshot(page.name); }); }); + + it('takes a screenshot of User Profile', () => { + cy.visit('/users/me'); + cy.getByTestId('ApiKey').then(($apiKey) => { + $apiKey.val('secret'); + }); + cy.percySnapshot('User Profile'); + }); }); diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js new file mode 100644 index 0000000000..1979371bba --- /dev/null +++ b/cypress/integration/user/edit_profile_spec.js @@ -0,0 +1,17 @@ +describe('Edit Profile', () => { + beforeEach(() => { + cy.login(); + cy.visit('/users/me'); + }); + + it('regenerates API Key', () => { + cy.getByTestId('ApiKey').then(($apiKey) => { + const previousApiKey = $apiKey.val(); + + cy.getByTestId('RegenerateApiKey').click(); + cy.get('.ant-btn-primary').contains('Regenerate').click({ force: true }); + + cy.getByTestId('ApiKey').should('not.eq', previousApiKey); + }); + }); +}); From e4f83ab519046870c2aa79c5e8243e27e668d20d Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 4 Feb 2019 01:03:54 -0200 Subject: [PATCH 21/35] Move User profile screenshot to Edit Profile Spec --- cypress/integration/percy/page_screenshots.js | 13 +------------ cypress/integration/user/edit_profile_spec.js | 7 +++++++ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/cypress/integration/percy/page_screenshots.js b/cypress/integration/percy/page_screenshots.js index a887b04fdc..15d2d54fe5 100644 --- a/cypress/integration/percy/page_screenshots.js +++ b/cypress/integration/percy/page_screenshots.js @@ -9,23 +9,12 @@ const pages = [ ]; describe('Percy Page Screenshots', () => { - beforeEach(() => { - cy.login(); - }); - pages.forEach((page) => { it(`takes a screenshot of ${page.name}`, () => { + cy.login(); cy.visit(page.url); cy.wait(1000); cy.percySnapshot(page.name); }); }); - - it('takes a screenshot of User Profile', () => { - cy.visit('/users/me'); - cy.getByTestId('ApiKey').then(($apiKey) => { - $apiKey.val('secret'); - }); - cy.percySnapshot('User Profile'); - }); }); diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index 1979371bba..472ebad933 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -14,4 +14,11 @@ describe('Edit Profile', () => { cy.getByTestId('ApiKey').should('not.eq', previousApiKey); }); }); + + it('takes a screenshot', () => { + cy.getByTestId('ApiKey').then(($apiKey) => { + $apiKey.val('secret'); + }); + cy.percySnapshot('User Profile'); + }); }); From 80d5809930089ff7486a9b34a3879606cd8b8459 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 5 Feb 2019 00:03:20 -0200 Subject: [PATCH 22/35] Add tests for saving user and changing password errors --- client/app/components/users/UserEdit.jsx | 6 ++- cypress/integration/user/edit_profile_spec.js | 37 +++++++++++++++++++ cypress/support/commands.js | 25 +++++-------- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 02a488f708..165516f6ad 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -46,7 +46,7 @@ export class UserEdit extends React.Component { if (values.password === values.password_repeat) { this.onSaveUser(values, successCallback, errorCallback); } else { - errorCallback('Passwords don\'t match!'); + errorCallback('Passwords don\'t match.'); } } @@ -148,7 +148,9 @@ export class UserEdit extends React.Component { > - + ); } diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index 472ebad933..520505ac35 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -1,9 +1,30 @@ +function fillProfileDataAndSave(name, email) { + cy.getByTestId('Name').type(`{selectall}${name}`); + cy.getByTestId('Email').type(`{selectall}${email}{enter}`); + cy.contains('Saved.'); +} + +function fillChangePasswordAndSave(currentPassword, newPassword, repeatPassword) { + cy.getByTestId('Current Password').type(currentPassword); + cy.getByTestId('New Password').type(newPassword); + cy.getByTestId('Repeat New Password').type(`${repeatPassword}{enter}`); +} + describe('Edit Profile', () => { beforeEach(() => { cy.login(); cy.visit('/users/me'); }); + it('updates the user after Save', () => { + fillProfileDataAndSave('Jian Yang', 'jian.yang@redash.io'); + cy.logout(); + cy.login('jian.yang@redash.io').its('status').should('eq', 200); + cy.visit('/users/me'); + cy.contains('Jian Yang'); + fillProfileDataAndSave('Example Admin', 'admin@redash.io'); + }); + it('regenerates API Key', () => { cy.getByTestId('ApiKey').then(($apiKey) => { const previousApiKey = $apiKey.val(); @@ -21,4 +42,20 @@ describe('Edit Profile', () => { }); cy.percySnapshot('User Profile'); }); + + context('changing password', () => { + beforeEach(() => { + cy.getByTestId('ChangePassword').click(); + }); + + it('shows an error when current password is wrong', () => { + fillChangePasswordAndSave('wrongpassword', 'newpassword', 'newpassword'); + cy.contains('Incorrect current password.'); + }); + + it('shows an error when new password does not match repeat password', () => { + fillChangePasswordAndSave('password', 'newpassword', 'differentpassword'); + cy.contains('Passwords don\'t match.'); + }); + }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 91edf307b6..3b5f00c8e4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,19 +1,14 @@ import '@percy/cypress'; -Cypress.Commands.add('login', () => { - const users = { - admin: { - email: 'admin@redash.io', - password: 'password', - }, - }; - - cy.request({ - url: '/login', - method: 'POST', - form: true, - body: users.admin, - }); -}); +Cypress.Commands.add('login', (email = 'admin@redash.io', password = 'password') => cy.request({ + url: '/login', + method: 'POST', + form: true, + body: { + email, + password, + }, +})); +Cypress.Commands.add('logout', () => cy.request('/logout')); Cypress.Commands.add('getByTestId', element => cy.get('[data-test="' + element + '"]')); From 3522cdc8dc2f0e287c17645bbbd9fcf9f1c44bb4 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 5 Feb 2019 00:09:13 -0200 Subject: [PATCH 23/35] CC is back :) - Fix trailing spaces --- cypress/integration/user/edit_profile_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index 520505ac35..3c012b4f89 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -31,7 +31,7 @@ describe('Edit Profile', () => { cy.getByTestId('RegenerateApiKey').click(); cy.get('.ant-btn-primary').contains('Regenerate').click({ force: true }); - + cy.getByTestId('ApiKey').should('not.eq', previousApiKey); }); }); From 74a77c51968cac51e90e7893e7fbdf479e111a4c Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 5 Feb 2019 19:31:18 -0200 Subject: [PATCH 24/35] Add test for succesful password update --- cypress/integration/user/edit_profile_spec.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index 3c012b4f89..c7f43be70a 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -48,6 +48,17 @@ describe('Edit Profile', () => { cy.getByTestId('ChangePassword').click(); }); + it('updates user password when password is correct', () => { + fillChangePasswordAndSave('password', 'newpassword', 'newpassword'); + cy.contains('Saved.'); + cy.logout(); + cy.login(undefined, 'newpassword').its('status').should('eq', 200); + cy.visit('/users/me'); + cy.getByTestId('ChangePassword').click(); + fillChangePasswordAndSave('newpassword', 'password', 'password'); + cy.contains('Saved.'); + }); + it('shows an error when current password is wrong', () => { fillChangePasswordAndSave('wrongpassword', 'newpassword', 'newpassword'); cy.contains('Incorrect current password.'); From a89ae71cfd494d067e804873bcbfa9b31b93334b Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 7 Feb 2019 22:53:02 -0200 Subject: [PATCH 25/35] A few improvements from code review --- .../components/dynamic-form/DynamicForm.jsx | 11 ++-- client/app/components/users/UserEdit.jsx | 60 ++++++++++--------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 6c075894b5..977e773a9c 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -7,20 +7,19 @@ import Checkbox from 'antd/lib/checkbox'; import Button from 'antd/lib/button'; import Upload from 'antd/lib/upload'; import Icon from 'antd/lib/icon'; +import { includes } from 'lodash'; import { react2angular } from 'react2angular'; import { toastr } from '@/services/ng'; import { Field, Action, AntdForm } from '../proptypes'; import helper from './dynamicFormHelper'; -const fieldRules = ({ title, name, type, required, minLength }) => { - const fieldLabel = title || helper.toHuman(name); - +const fieldRules = ({ type, required, minLength }) => { const requiredRule = required; - const minLengthRule = minLength && ['text', 'email', 'password'].includes(type); + const minLengthRule = minLength && includes(['text', 'email', 'password'], type); return [ - requiredRule && { required, message: `${fieldLabel} is required.` }, - minLengthRule && { min: minLength, message: `${fieldLabel} is too short.` }, + requiredRule && { required, message: 'This field is required.' }, + minLengthRule && { min: minLength, message: 'This field is too short.' }, ].filter(rule => rule); }; diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 165516f6ad..7cb5cf55f3 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -22,36 +22,15 @@ export class UserEdit extends React.Component { super(props); this.state = { user: this.props.user, - changingPassword: false, + passwordModalIsOpen: false, sendingPasswordEmail: false, resendingInvitation: false, + togglingUser: false, }; } - onSaveUser = (values, successCallback, errorCallback) => { - const data = { - id: this.props.user.id, - ...values, - }; - - User.save(data, (user) => { - successCallback('Saved.'); - this.setState({ user: User.convertUserInfo(user) }); - }, (error) => { - errorCallback(error.data.message || 'Failed saving.'); - }); - }; - - onUpdatePassword = (values, successCallback, errorCallback) => { - if (values.password === values.password_repeat) { - this.onSaveUser(values, successCallback, errorCallback); - } else { - errorCallback('Passwords don\'t match.'); - } - } - onClickChangePassword = () => { - this.setState({ changingPassword: true }); + this.setState({ passwordModalIsOpen: true }); }; onClickSendPasswordReset = () => { @@ -75,7 +54,8 @@ export class UserEdit extends React.Component { onClickRegenerateApiKey = () => { const doRegenerate = () => { User.regenerateApiKey(this.state.user).then((apiKey) => { - this.setState(prevState => ({ user: { ...prevState.user, apiKey } })); + const { user } = this.state; + this.setState({ user: { ...user, apiKey } }); }); }; @@ -101,6 +81,28 @@ export class UserEdit extends React.Component { }); }; + saveUser = (values, successCallback, errorCallback) => { + const data = { + id: this.props.user.id, + ...values, + }; + + User.save(data, (user) => { + successCallback('Saved.'); + this.setState({ user: User.convertUserInfo(user) }); + }, (error = {}) => { + errorCallback(error.data && error.data.message || 'Failed saving.'); + }); + }; + + updatePassword = (values, successCallback, errorCallback) => { + if (values.password === values.password_repeat) { + this.saveUser(values, successCallback, errorCallback); + } else { + errorCallback('Passwords don\'t match.'); + } + } + renderBasicInfoForm() { const { user } = this.state; const formFields = [ @@ -124,7 +126,7 @@ export class UserEdit extends React.Component { ); } @@ -140,13 +142,13 @@ export class UserEdit extends React.Component {
{ this.setState({ changingPassword: false }); }} + onCancel={() => { this.setState({ passwordModalIsOpen: false }); }} footer={null} destroyOnClose > - +
@@ -163,24 +163,20 @@ export class UserEdit extends React.Component { } renderApiKey() { - const { user } = this.state; - - const regenerateButton = ( - - - - ); + const { user, regeneratingApiKey } = this.state; return (

- + +
); @@ -213,7 +209,7 @@ export class UserEdit extends React.Component { return ( ) : ( - ); @@ -269,6 +265,7 @@ export class UserEdit extends React.Component { {!user.isDisabled && ( {this.renderApiKey()} +

Password


{this.renderChangePassword()} {currentUser.isAdmin && ( user.isInvitationPending ? @@ -276,7 +273,8 @@ export class UserEdit extends React.Component { )}
)} - {currentUser.isAdmin && user.id !== currentUser.id && this.renderToggleUser()} +
+ {currentUser.isAdmin && user.id !== currentUser.id && this.rendertoggleUser()} ); } From 02899239b412fde93c742524e7f621d716e058e5 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sun, 10 Feb 2019 17:30:23 -0200 Subject: [PATCH 31/35] Update UserEdit render behavior and styling - Password title changed to h5 - change rendering rules for actions - Password modal is now closed when password is changed - change DynamicForm readOnly to the fields and add hideSubmitButton --- .../components/dynamic-form/DynamicForm.jsx | 12 +++--- client/app/components/proptypes.js | 1 + client/app/components/users/UserEdit.jsx | 37 +++++++++++-------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 977e773a9c..1d030558da 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -28,7 +28,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone fields: PropTypes.arrayOf(Field), actions: PropTypes.arrayOf(Action), feedbackIcons: PropTypes.bool, - readOnly: PropTypes.bool, + hideSubmitButton: PropTypes.bool, saveText: PropTypes.string, onSubmit: PropTypes.func, form: AntdForm.isRequired, @@ -38,7 +38,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone fields: [], actions: [], feedbackIcons: false, - readOnly: false, + hideSubmitButton: false, saveText: 'Save', onSubmit: () => {}, }; @@ -155,9 +155,9 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone return this.props.fields.map((field) => { const [firstField] = this.props.fields; const FormItem = Form.Item; - const { name, title, type } = field; + const { name, title, type, readOnly } = field; const fieldLabel = title || helper.toHuman(name); - const { feedbackIcons, readOnly } = this.props; + const { feedbackIcons } = this.props; const formItemProps = { key: name, @@ -207,8 +207,8 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone disabled: this.state.isSubmitting, loading: this.state.isSubmitting, }; - const { readOnly, saveText } = this.props; - const saveButton = !readOnly; + const { hideSubmitButton, saveText } = this.props; + const saveButton = !hideSubmitButton; return (
diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index dac27a311f..4dc8abf4c8 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -37,6 +37,7 @@ export const Field = PropTypes.shape({ type: PropTypes.oneOf(['text', 'email', 'password', 'number', 'checkbox', 'file']).isRequired, initialValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), required: PropTypes.bool, + readOnly: PropTypes.bool, minLength: PropTypes.number, placeholder: PropTypes.string, }); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index c969b722c6..e86b6b8c0b 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -102,8 +102,13 @@ export class UserEdit extends React.Component { }; updatePassword = (values, successCallback, errorCallback) => { + const updatePasswordSuccess = (message) => { + this.setState({ passwordModalIsOpen: false }); + successCallback(message); + }; + if (values.password === values.password_repeat) { - this.saveUser(values, successCallback, errorCallback); + this.saveUser(values, updatePasswordSuccess, errorCallback); } else { errorCallback('Passwords don\'t match.'); } @@ -117,22 +122,20 @@ export class UserEdit extends React.Component { title: 'Name', type: 'text', initialValue: user.name, - required: true, }, { name: 'email', title: 'Email', type: 'email', initialValue: user.email, - required: true, }, - ]; + ].map(field => ({ ...field, readOnly: user.isDisabled, required: true })); return ( ); } @@ -168,16 +171,17 @@ export class UserEdit extends React.Component { return (
- + - + ); } @@ -265,9 +269,10 @@ export class UserEdit extends React.Component { {!user.isDisabled && ( {this.renderApiKey()} -

Password


- {this.renderChangePassword()} - {currentUser.isAdmin && ( +
+
Password
+ {user.id === currentUser.id && this.renderChangePassword()} + {(currentUser.isAdmin && user.id !== currentUser.id) && ( user.isInvitationPending ? this.renderResendInvitation() : this.renderSendPasswordReset() )} From 2e8756fedeaed03e6f94959dfec797d0d11eb52d Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 13 Feb 2019 15:22:50 -0200 Subject: [PATCH 32/35] Create ChangePasswordDialog and update UserEdit --- .../components/users/ChangePasswordDialog.jsx | 146 ++++++++++++++++++ client/app/components/users/UserEdit.jsx | 48 +----- cypress/integration/user/edit_profile_spec.js | 6 +- 3 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 client/app/components/users/ChangePasswordDialog.jsx diff --git a/client/app/components/users/ChangePasswordDialog.jsx b/client/app/components/users/ChangePasswordDialog.jsx new file mode 100644 index 0000000000..4b0193770e --- /dev/null +++ b/client/app/components/users/ChangePasswordDialog.jsx @@ -0,0 +1,146 @@ +import React from 'react'; +import Form from 'antd/lib/form'; +import Modal from 'antd/lib/modal'; +import Input from 'antd/lib/input'; +import { isFunction } from 'lodash'; +import { User } from '@/services/user'; +import { toastr } from '@/services/ng'; +import { UserProfile } from '../proptypes'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; + +class ChangePasswordDialog extends React.Component { + static propTypes = { + user: UserProfile.isRequired, + dialog: DialogPropType.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + currentPassword: { value: '', error: null, touched: false }, + newPassword: { value: '', error: null, touched: false }, + repeatPassword: { value: '', error: null, touched: false }, + updatingPassword: false, + }; + } + + fieldError = (name, value) => { + if (value.length === 0) return 'This field is required.'; + if (name !== 'currentPassword' && value.length < 6) return 'This field is too short.'; + if (name === 'repeatPassword' && value !== this.state.newPassword.value) return 'Password don\'t match'; + return null; + }; + + validateFields = (callback) => { + const { currentPassword, newPassword, repeatPassword } = this.state; + + const errors = { + currentPassword: this.fieldError('currentPassword', currentPassword.value), + newPassword: this.fieldError('newPassword', newPassword.value), + repeatPassword: this.fieldError('repeatPassword', repeatPassword.value), + }; + + this.setState({ + currentPassword: { ...currentPassword, error: errors.currentPassword }, + newPassword: { ...newPassword, error: errors.newPassword }, + repeatPassword: { ...repeatPassword, error: errors.repeatPassword }, + }); + + if (isFunction(callback)) { + if (errors.currentPassword || errors.newPassword || errors.repeatPassword) { + callback(errors); + } else callback(null); + } + } + + updatePassword = () => { + const { currentPassword, newPassword, updatingPassword } = this.state; + + if (!updatingPassword) { + this.validateFields((err) => { + if (!err) { + const userData = { + id: this.props.user.id, + old_password: currentPassword.value, + password: newPassword.value, + }; + + this.setState({ updatingPassword: true }); + + User.save(userData, () => { + toastr.success('Saved.'); + this.props.dialog.close({ success: true }); + }, (error = {}) => { + toastr.error(error.data && error.data.message || 'Failed saving.'); + this.setState({ updatingPassword: false }); + }); + } else { + this.setState(prevState => ({ + currentPassword: { ...prevState.currentPassword, touched: true }, + newPassword: { ...prevState.newPassword, touched: true }, + repeatPassword: { ...prevState.repeatPassword, touched: true }, + })); + } + }); + } + } + + handleChange = (e) => { + const { name, value } = e.target; + const { error } = this.state[name]; + + this.setState({ [name]: { value, error, touched: true } }, () => { + this.validateFields(); + }); + } + + render() { + const { dialog } = this.props; + const { currentPassword, newPassword, repeatPassword, updatingPassword } = this.state; + + const formItemProps = { className: 'm-b-10', required: true }; + + const inputProps = { + onChange: this.handleChange, + onPressEnter: this.updatePassword, + }; + + return ( + +
+ + + + + + + + + +
+
+ ); + } +} + +export default wrapDialog(ChangePasswordDialog); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index e86b6b8c0b..e4f06cbc7f 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -9,6 +9,7 @@ import { currentUser } from '@/services/auth'; import { absoluteUrl } from '@/services/utils'; import { UserProfile } from '../proptypes'; import { DynamicForm } from '../dynamic-form/DynamicForm'; +import ChangePasswordDialog from './ChangePasswordDialog'; import InputWithCopy from '../InputWithCopy'; export class UserEdit extends React.Component { @@ -20,7 +21,6 @@ export class UserEdit extends React.Component { super(props); this.state = { user: this.props.user, - passwordModalIsOpen: false, regeneratingApiKey: false, sendingPasswordEmail: false, resendingInvitation: false, @@ -29,7 +29,7 @@ export class UserEdit extends React.Component { } changePassword = () => { - this.setState({ passwordModalIsOpen: true }); + ChangePasswordDialog.showModal({ user: this.props.user }); }; sendPasswordReset = () => { @@ -101,19 +101,6 @@ export class UserEdit extends React.Component { }); }; - updatePassword = (values, successCallback, errorCallback) => { - const updatePasswordSuccess = (message) => { - this.setState({ passwordModalIsOpen: false }); - successCallback(message); - }; - - if (values.password === values.password_repeat) { - this.saveUser(values, updatePasswordSuccess, errorCallback); - } else { - errorCallback('Passwords don\'t match.'); - } - } - renderBasicInfoForm() { const { user } = this.state; const formFields = [ @@ -140,31 +127,6 @@ export class UserEdit extends React.Component { ); } - renderChangePassword() { - const fields = [ - { name: 'old_password', title: 'Current Password' }, - { name: 'password', title: 'New Password', minLength: 6 }, - { name: 'password_repeat', title: 'Repeat New Password' }, - ].map(field => ({ ...field, type: 'password', required: true })); - - return ( - - { this.setState({ passwordModalIsOpen: false }); }} - footer={null} - destroyOnClose - > - - - - - ); - } - renderApiKey() { const { user, regeneratingApiKey } = this.state; @@ -271,7 +233,11 @@ export class UserEdit extends React.Component { {this.renderApiKey()}
Password
- {user.id === currentUser.id && this.renderChangePassword()} + {user.id === currentUser.id && ( + + )} {(currentUser.isAdmin && user.id !== currentUser.id) && ( user.isInvitationPending ? this.renderResendInvitation() : this.renderSendPasswordReset() diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index c7f43be70a..6c6753837b 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -5,9 +5,9 @@ function fillProfileDataAndSave(name, email) { } function fillChangePasswordAndSave(currentPassword, newPassword, repeatPassword) { - cy.getByTestId('Current Password').type(currentPassword); - cy.getByTestId('New Password').type(newPassword); - cy.getByTestId('Repeat New Password').type(`${repeatPassword}{enter}`); + cy.getByTestId('CurrentPassword').type(currentPassword); + cy.getByTestId('NewPassword').type(newPassword); + cy.getByTestId('RepeatPassword').type(`${repeatPassword}{enter}`); } describe('Edit Profile', () => { From f6dfdabe78c1f37c4ebc7531f3144dafc7d793d7 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 13 Feb 2019 15:23:07 -0200 Subject: [PATCH 33/35] Fix possible console error --- client/app/pages/users/show.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/pages/users/show.html b/client/app/pages/users/show.html index 5c5d2b9ad2..7c28a45149 100644 --- a/client/app/pages/users/show.html +++ b/client/app/pages/users/show.html @@ -3,7 +3,7 @@
- +
From d5a3c8f4c0834fb348be0aa60669f7d4a37d5c0b Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 13 Feb 2019 15:44:42 -0200 Subject: [PATCH 34/35] Remove password match assertion from spec --- cypress/integration/user/edit_profile_spec.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cypress/integration/user/edit_profile_spec.js b/cypress/integration/user/edit_profile_spec.js index 6c6753837b..99d3d58ee3 100644 --- a/cypress/integration/user/edit_profile_spec.js +++ b/cypress/integration/user/edit_profile_spec.js @@ -63,10 +63,5 @@ describe('Edit Profile', () => { fillChangePasswordAndSave('wrongpassword', 'newpassword', 'newpassword'); cy.contains('Incorrect current password.'); }); - - it('shows an error when new password does not match repeat password', () => { - fillChangePasswordAndSave('password', 'newpassword', 'differentpassword'); - cy.contains('Passwords don\'t match.'); - }); }); }); From 230f7e38fd97d07a06bdfcd44451560e52f06aaa Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 14 Feb 2019 13:34:04 -0200 Subject: [PATCH 35/35] Fix typo --- client/app/components/users/ChangePasswordDialog.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/components/users/ChangePasswordDialog.jsx b/client/app/components/users/ChangePasswordDialog.jsx index 4b0193770e..eb0b652462 100644 --- a/client/app/components/users/ChangePasswordDialog.jsx +++ b/client/app/components/users/ChangePasswordDialog.jsx @@ -27,7 +27,7 @@ class ChangePasswordDialog extends React.Component { fieldError = (name, value) => { if (value.length === 0) return 'This field is required.'; if (name !== 'currentPassword' && value.length < 6) return 'This field is too short.'; - if (name === 'repeatPassword' && value !== this.state.newPassword.value) return 'Password don\'t match'; + if (name === 'repeatPassword' && value !== this.state.newPassword.value) return 'Passwords don\'t match'; return null; };