Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manage user groups in UserEdit #3450

Merged
merged 24 commits into from
Mar 27, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3e7acdf
Update user handler groups -> group_ids
gabrieldutra Feb 17, 2019
f16bf83
Adjust proptypes to accept Select Fields
gabrieldutra Feb 19, 2019
3f918aa
Add Select Field support in DynamicForm
gabrieldutra Feb 19, 2019
c743c73
Manage User Groups in UserEdit
gabrieldutra Feb 19, 2019
db1b2bf
Merge remote-tracking branch 'master' into user-edit-manage-groups
gabrieldutra Feb 19, 2019
1177532
GroupSelect is not rendered when it's currentUser profile :facepalm:
gabrieldutra Feb 19, 2019
7cdbd27
Add loading to Groups select in UserEdit
gabrieldutra Feb 19, 2019
d0b7d85
Change DynamicForm Select optionFilterProp
gabrieldutra Feb 20, 2019
2844de1
Check in user handler if the groups are valid and not empty
gabrieldutra Feb 20, 2019
3f471ed
Merge remote-tracking branch 'master' into user-edit-manage-groups
gabrieldutra Feb 20, 2019
dc4967c
Remove whitespaces
gabrieldutra Feb 20, 2019
60695f9
Add group info to UserShow and readOnly to UserEdit
gabrieldutra Feb 20, 2019
870c3db
Render Groups as tags for read only in UserEdit
gabrieldutra Feb 21, 2019
8a065bf
Remove UserShow jest test
gabrieldutra Feb 21, 2019
c57d555
Adjust spacing between group tags
gabrieldutra Feb 21, 2019
4d82355
Merge remote-tracking branch 'master' into user-edit-manage-groups
gabrieldutra Feb 21, 2019
b58981f
Render read only group_ids as DynamicForm content
gabrieldutra Feb 21, 2019
3759aff
Move Group query to componentDidMount
gabrieldutra Feb 22, 2019
731a435
Merge remote-tracking branch 'master' into user-edit-manage-groups
gabrieldutra Feb 22, 2019
4dc614b
Fix UserShow jest spec with stub
gabrieldutra Feb 26, 2019
7e17a7b
Merge remote-tracking branch 'master' into user-edit-manage-groups
gabrieldutra Feb 26, 2019
417c3c2
Merge branch 'master' into user-edit-manage-groups
gabrieldutra Mar 14, 2019
9f1e128
Use link instead of onClick event for group tags
gabrieldutra Mar 14, 2019
be430af
Merge branch 'master' into user-edit-manage-groups
gabrieldutra Mar 27, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/app/assets/less/ant.less
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@import '~antd/lib/tag/style/index';
@import '~antd/lib/grid/style/index';
@import '~antd/lib/switch/style/index';
@import '~antd/lib/empty/style/index';
@import 'inc/ant-variables';

// Remove bold in labels for Ant checkboxes and radio buttons
Expand Down
22 changes: 22 additions & 0 deletions client/app/components/dynamic-form/DynamicForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 Select from 'antd/lib/select';
import { includes } from 'lodash';
import { react2angular } from 'react2angular';
import { toastr } from '@/services/ng';
Expand Down Expand Up @@ -130,6 +131,25 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone
return getFieldDecorator(name, fileOptions)(upload);
}

renderSelect(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, options, mode, initialValue, readOnly } = field;
const { Option } = Select;

const decoratorOptions = {
rules: fieldRules(field),
initialValue,
};

return getFieldDecorator(name, decoratorOptions)(
<Select {...props} mode={mode}>
{options && options.map(({ value, title }) => (
<Option key={`${value}`} value={value} disabled={readOnly}>{ title || value }</Option>
))}
</Select>,
);
}
gabrieldutra marked this conversation as resolved.
Show resolved Hide resolved

renderField(field, props) {
const { getFieldDecorator } = this.props.form;
const { name, type, initialValue } = field;
Expand All @@ -145,6 +165,8 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone
return getFieldDecorator(name, options)(<Checkbox {...props}>{fieldLabel}</Checkbox>);
} else if (type === 'file') {
return this.renderUpload(field, props);
} else if (type === 'select') {
return this.renderSelect(field, props);
} else if (type === 'number') {
return getFieldDecorator(name, options)(<InputNumber {...props} />);
}
Expand Down
11 changes: 9 additions & 2 deletions client/app/components/proptypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ export const RefreshScheduleDefault = {
export const Field = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
type: PropTypes.oneOf(['text', 'email', 'password', 'number', 'checkbox', 'file']).isRequired,
initialValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
type: PropTypes.oneOf(['text', 'email', 'password', 'number', 'checkbox', 'file', 'select']).isRequired,
initialValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.arrayOf(PropTypes.string),
PropTypes.arrayOf(PropTypes.number),
]),
mode: PropTypes.string,
gabrieldutra marked this conversation as resolved.
Show resolved Hide resolved
required: PropTypes.bool,
readOnly: PropTypes.bool,
minLength: PropTypes.number,
Expand Down
27 changes: 23 additions & 4 deletions client/app/components/users/UserEdit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import Button from 'antd/lib/button';
import Form from 'antd/lib/form';
import Modal from 'antd/lib/modal';
import { react2angular } from 'react2angular';
import { includes, reject, isNull } from 'lodash';
import { User } from '@/services/user';
import { Group } from '@/services/group';
import { currentUser } from '@/services/auth';
import { absoluteUrl } from '@/services/utils';
import { UserProfile } from '../proptypes';
Expand All @@ -21,11 +23,16 @@ export class UserEdit extends React.Component {
super(props);
this.state = {
user: this.props.user,
groups: [],
regeneratingApiKey: false,
sendingPasswordEmail: false,
resendingInvitation: false,
togglingUser: false,
};

Group.query((groups) => {
this.setState({ groups: groups.map(({ id, name }) => ({ value: id, title: name })) });
});
}

changePassword = () => {
Expand Down Expand Up @@ -101,22 +108,34 @@ export class UserEdit extends React.Component {
});
};

renderBasicInfoForm() {
renderUserInfoForm() {
const { user } = this.state;
const formFields = [

const formFields = reject([
{
name: 'name',
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 }));
(currentUser.isAdmin && currentUser.id !== user.id) ? {
name: 'group_ids',
title: 'Groups',
type: 'select',
mode: 'multiple',
options: this.state.groups,
initialValue: this.state.groups.filter(group => includes(user.groupIds, group.value))
.map(group => group.value),
} : null,
], isNull).map(field => ({ ...field, readOnly: user.isDisabled }));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering that it might take some time to load the groups, maybe have some loading state for the data? I know Ant's Select components have support for this, so it's only a matter of communicating this between the components.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, locally it's already done, I also added a "Loading..." placeholder. (better than a whole blank select imo)


return (
<DynamicForm
Expand Down Expand Up @@ -227,7 +246,7 @@ export class UserEdit extends React.Component {
/>
<h3 className="profile__h3">{user.name}</h3>
<hr />
{this.renderBasicInfoForm()}
{this.renderUserInfoForm()}
{!user.isDisabled && (
<Fragment>
{this.renderApiKey()}
Expand Down
1 change: 1 addition & 0 deletions client/app/services/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function convertUserInfo(user) {
email: user.email,
profileImageUrl: user.profile_image_url,
apiKey: user.api_key,
groupIds: user.groups,
isDisabled: user.is_disabled,
isInvitationPending: user.is_invitation_pending,
};
Expand Down
1 change: 1 addition & 0 deletions cypress/integration/user/edit_profile_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ describe('Edit Profile', () => {
});

it('takes a screenshot', () => {
cy.getByTestId('Groups').contains('admin');
cy.getByTestId('ApiKey').then(($apiKey) => {
$apiKey.val('secret');
});
Expand Down
4 changes: 2 additions & 2 deletions redash/handlers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def post(self, user_id):

req = request.get_json(True)

params = project(req, ('email', 'name', 'password', 'old_password', 'groups'))
params = project(req, ('email', 'name', 'password', 'old_password', 'group_ids'))

if 'password' in params and 'old_password' not in params:
abort(403, message="Must provide current password to update password.")
Expand All @@ -216,7 +216,7 @@ def post(self, user_id):
user.hash_password(params.pop('password'))
params.pop('old_password')

if 'groups' in params and not self.current_user.has_permission('admin'):
if 'group_ids' in params and not self.current_user.has_permission('admin'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check that the group ids belong to the current org.

Copy link
Member Author

@gabrieldutra gabrieldutra Feb 19, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should rather abort with an error message or simply remove the non-belonging groups? (in frontend side I guess those groups won't even appear, this is more to guarantee the behavior)

  • We shouldn't allow removing all groups from a user, a user should always have at least 1 group association.

I guess I should add the rule here in the backend as well (frontend it was as easy as putting the field required)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arikfr, please check and consider me inexperienced in those parts 😂

abort(403, message="Must be admin to change groups membership.")

if 'email' in params:
Expand Down
9 changes: 9 additions & 0 deletions tests/handlers/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ def test_changing_email_does_not_end_current_session(self):
# make sure the session's `user_id` has changed to reflect the new identity, thus not logging the user out
self.assertNotEquals(previous, current)

def test_admin_can_change_user_groups(self):
admin_user = self.factory.create_admin()
other_user = self.factory.create_user(group_ids=[1])

rv = self.make_request('post', "/api/users/{}".format(other_user.id), data={"group_ids": []}, user=admin_user)

self.assertEqual(rv.status_code, 200)
self.assertEqual(models.User.query.get(other_user.id).group_ids, [])

def test_admin_can_delete_user(self):
admin_user = self.factory.create_admin()
other_user = self.factory.create_user(is_invitation_pending=True)
Expand Down