@plone/volto
Version:
Volto
789 lines (761 loc) • 23.9 kB
JSX
/**
* Users controlpanel container.
* @module components/manage/Controlpanels/UsersControlpanel
*/
import {
createUser,
deleteUser,
listUsers,
updateUser,
getUser,
} from '@plone/volto/actions/users/users';
import { listRoles } from '@plone/volto/actions/roles/roles';
import { listGroups, updateGroup } from '@plone/volto/actions/groups/groups';
import { getControlpanel } from '@plone/volto/actions/controlpanels/controlpanels';
import { getUserSchema } from '@plone/volto/actions/userschema/userschema';
import jwtDecode from 'jwt-decode';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import Toast from '@plone/volto/components/manage/Toast/Toast';
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
import Pagination from '@plone/volto/components/theme/Pagination/Pagination';
import Error from '@plone/volto/components/theme/Error/Error';
import { ModalForm } from '@plone/volto/components/manage/Form';
import RenderUsers from '@plone/volto/components/manage/Controlpanels/Users/RenderUsers';
import { Link } from 'react-router-dom';
import Helmet from '@plone/volto/helpers/Helmet/Helmet';
import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels';
import { isManager, canAssignGroup } from '@plone/volto/helpers/User/User';
import clearSVG from '@plone/volto/icons/clear.svg';
import addUserSvg from '@plone/volto/icons/add-user.svg';
import saveSVG from '@plone/volto/icons/save.svg';
import ploneSVG from '@plone/volto/icons/plone.svg';
import find from 'lodash/find';
import map from 'lodash/map';
import pull from 'lodash/pull';
import difference from 'lodash/difference';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { createPortal } from 'react-dom';
import { connect } from 'react-redux';
import { toast } from 'react-toastify';
import { bindActionCreators, compose } from 'redux';
import {
Confirm,
Container,
Form,
Input,
Button,
Dimmer,
Segment,
Table,
Loader,
} from 'semantic-ui-react';
/**
* UsersControlpanel class.
* @class UsersControlpanel
* @extends Component
*/
class UsersControlpanel extends Component {
/**
* Property types.
* @property {Object} propTypes Property types.
* @static
*/
static propTypes = {
listRoles: PropTypes.func.isRequired,
listUsers: PropTypes.func.isRequired,
updateUser: PropTypes.func,
listGroups: PropTypes.func.isRequired,
pathname: PropTypes.string.isRequired,
roles: PropTypes.arrayOf(
PropTypes.shape({
'@id': PropTypes.string,
'@type': PropTypes.string,
id: PropTypes.string,
}),
).isRequired,
users: PropTypes.arrayOf(
PropTypes.shape({
username: PropTypes.string,
fullname: PropTypes.string,
roles: PropTypes.arrayOf(PropTypes.string),
}),
).isRequired,
user: PropTypes.shape({
'@id': PropTypes.string,
id: PropTypes.string,
description: PropTypes.string,
email: PropTypes.string,
fullname: PropTypes.string,
groups: PropTypes.object,
location: PropTypes.string,
portrait: PropTypes.string,
home_page: PropTypes.string,
roles: PropTypes.arrayOf(PropTypes.string),
username: PropTypes.string,
}).isRequired,
};
/**
* Constructor
* @method constructor
* @param {Object} props Component properties
* @constructs Sharing
*/
constructor(props) {
super(props);
this.onChangeSearch = this.onChangeSearch.bind(this);
this.onSearch = this.onSearch.bind(this);
this.delete = this.delete.bind(this);
this.onDeleteOk = this.onDeleteOk.bind(this);
this.onDeleteCancel = this.onDeleteCancel.bind(this);
this.onAddUserSubmit = this.onAddUserSubmit.bind(this);
this.onAddUserError = this.onAddUserError.bind(this);
this.onAddUserSuccess = this.onAddUserSuccess.bind(this);
this.updateUserRole = this.updateUserRole.bind(this);
this.state = {
search: '',
isLoading: false,
showAddUser: false,
showAddUserErrorConfirm: false,
addUserError: '',
showDelete: false,
userToDelete: undefined,
entries: [],
isClient: false,
currentPage: 0,
pageSize: 10,
loginUsingEmail: false,
};
}
fetchData = async () => {
await this.props.getControlpanel('usergroup');
await this.props.listRoles();
if (!this.props.many_users) {
this.props.listGroups();
await this.props.listUsers();
this.setState({
entries: this.props.users,
});
}
await this.props.getUserSchema();
await this.props.getUser(this.props.userId);
};
// Because username field needs to be disabled if email login is enabled!
checkLoginUsingEmailStatus = async () => {
await this.props.getControlpanel('security');
this.setState({
loginUsingEmail: this.props.controlPanelData?.data.use_email_as_login,
});
};
/**
* Component did mount
* @method componentDidMount
* @returns {undefined}
*/
componentDidMount() {
this.setState({
isClient: true,
});
this.fetchData();
this.checkLoginUsingEmailStatus();
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (
(this.props.deleteRequest.loading && nextProps.deleteRequest.loaded) ||
(this.props.createRequest.loading && nextProps.createRequest.loaded)
) {
this.props.listUsers({
search: this.state.search,
});
}
if (this.props.deleteRequest.loading && nextProps.deleteRequest.loaded) {
this.onDeleteUserSuccess();
}
if (this.props.createRequest.loading && nextProps.createRequest.loaded) {
this.onAddUserSuccess();
}
if (this.props.createRequest.loading && nextProps.createRequest.error) {
this.onAddUserError(nextProps.createRequest.error);
}
if (
this.props.loadRolesRequest.loading &&
nextProps.loadRolesRequest.error
) {
this.setState({
error: nextProps.loadRolesRequest.error,
});
}
}
getUserFromProps(value) {
return find(this.props.users, ['@id', value]);
}
/**
* Search handler
* @method onSearch
* @param {object} event Event object.
* @returns {undefined}
*/
onSearch(event) {
event.preventDefault();
this.setState({ isLoading: true });
this.props
.listUsers({
search: this.state.search,
})
.then(() => {
this.setState({ isLoading: false });
})
.catch((error) => {
this.setState({ isLoading: false });
// eslint-disable-next-line no-console
console.error('Error searching users', error);
});
}
/**
* On change search handler
* @method onChangeSearch
* @param {object} event Event object.
* @returns {undefined}
*/
onChangeSearch(event) {
this.setState({
search: event.target.value,
});
}
/**
* Delete a user
* @method delete
* @param {object} event Event object.
* @param {string} value username.
* @returns {undefined}
*/
delete(event, { value }) {
if (value) {
this.setState({
showDelete: true,
userToDelete: this.getUserFromProps(value),
});
}
}
/**
* On delete ok
* @method onDeleteOk
* @returns {undefined}
*/
onDeleteOk() {
if (this.state.userToDelete) {
this.props.deleteUser(this.state.userToDelete.id);
}
}
/**
* On delete cancel
* @method onDeleteCancel
* @returns {undefined}
*/
onDeleteCancel() {
this.setState({
showDelete: false,
itemsToDelete: [],
userToDelete: undefined,
});
}
/**
*@param {object} user
*@returns {undefined}
*@memberof UsersControlpanel
*/
addUserToGroup = (user) => {
const { groups, username } = user;
groups.forEach((group) => {
this.props.updateGroup(group, {
users: {
[username]: true,
},
});
});
};
/**
* Callback to be called by the ModalForm when the form is submitted.
*
* @param {object} data Form data from the ModalForm.
* @param {func} callback to set new form data in the ModalForm
* @returns {undefined}
*/
onAddUserSubmit(data, callback) {
const { groups, sendPasswordReset, password } = data;
if (
sendPasswordReset !== undefined &&
sendPasswordReset === true &&
password !== undefined
) {
toast.error(
<Toast
error
title={this.props.intl.formatMessage(messages.error)}
content={this.props.intl.formatMessage(
messages.addUserFormPasswordAndSendPasswordTogetherNotAllowed,
)}
/>,
);
} else {
if (groups && groups.length > 0) this.addUserToGroup(data);
this.props.createUser(data, sendPasswordReset);
this.setState({
addUserSetFormDataCallback: callback,
});
}
}
/**
* Handle Success after createUser()
*
* @returns {undefined}
*/
onAddUserSuccess() {
this.state.addUserSetFormDataCallback({});
this.setState({
showAddUser: false,
addUserError: undefined,
addUserSetFormDataCallback: undefined,
});
toast.success(
<Toast
success
title={this.props.intl.formatMessage(messages.success)}
content={this.props.intl.formatMessage(messages.userCreated)}
/>,
);
}
/**
* Handle Success after deleteUser()
*
* @returns {undefined}
*/
onDeleteUserSuccess() {
this.setState({
userToDelete: undefined,
showDelete: false,
});
toast.success(
<Toast
success
title={this.props.intl.formatMessage(messages.success)}
content={this.props.intl.formatMessage(messages.userDeleted)}
/>,
);
}
/**
*
*
* @param {*} data
* @param {*} callback
* @memberof UsersControlpanel
*/
updateUserRole(name, value) {
this.setState({
entries: map(this.state.entries, (entry) => ({
...entry,
roles:
entry.id === name && !entry.roles.includes(value)
? [...entry.roles, value]
: entry.id !== name
? entry.roles
: pull(entry.roles, value),
})),
});
}
/**
*
* @param {*} event
* @memberof UsersControlpanel
*/
updateUserRoleSubmit = (e) => {
e.stopPropagation();
const roles = this.props.roles.map((item) => item.id);
this.state.entries.forEach((item) => {
const userData = { roles: {} };
const removedRoles = difference(roles, item.roles);
removedRoles.forEach((role) => {
userData.roles[role] = false;
});
item.roles.forEach((role) => {
userData.roles[role] = true;
});
this.props.updateUser(item.id, userData);
});
toast.success(
<Toast
success
title={this.props.intl.formatMessage(messages.success)}
content={this.props.intl.formatMessage(messages.updateRoles)}
/>,
);
};
/**
* Handle Errors after createUser()
*
* @param {object} error object. Requires the property .message
* @returns {undefined}
*/
onAddUserError(error) {
this.setState({
addUserError: error.response.body.error.message,
});
}
/**
* On change page
* @method onChangePage
* @param {object} event Event object.
* @param {string} value Page value.
* @returns {undefined}
*/
onChangePage = (event, { value }) => {
this.setState({
currentPage: value,
});
};
componentDidUpdate(prevProps, prevState) {
if (this.props.users !== prevProps.users) {
this.setState({
entries: this.props.users,
});
}
}
/**
* Filters the roles a user can assign when adding a user.
* @method canAssignAdd
* @returns {arry}
*/
canAssignAdd(isManager) {
if (isManager) return this.props.roles;
return this.props.user?.roles
? this.props.roles.filter((role) =>
this.props.user.roles.includes(role.id),
)
: [];
}
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
if (this.state.error) {
return <Error error={this.state.error} />;
}
/*let fullnameToDelete = this.state.userToDelete
? this.state.userToDelete.fullname
: '';*/
let usernameToDelete = this.state.userToDelete
? this.state.userToDelete.username
: '';
// Copy the userschema using JSON serialization/deserialization
// this is really ugly, but if we don't do this the original value
// of the userschema is changed and it is used like that through
// the lifecycle of the application
let adduserschema = {};
let isUserManager = false;
if (this.props?.userschema?.loaded) {
isUserManager = isManager(this.props.user);
adduserschema = JSON.parse(
JSON.stringify(this.props?.userschema?.userschema),
);
adduserschema.properties['username'] = {
title: this.props.intl.formatMessage(messages.addUserFormUsernameTitle),
type: 'string',
description: this.props.intl.formatMessage(
messages.addUserFormUsernameDescription,
),
};
adduserschema.properties['password'] = {
title: this.props.intl.formatMessage(messages.addUserFormPasswordTitle),
type: 'password',
description: this.props.intl.formatMessage(
messages.addUserFormPasswordDescription,
),
widget: 'password',
};
adduserschema.properties['sendPasswordReset'] = {
title: this.props.intl.formatMessage(
messages.addUserFormSendPasswordResetTitle,
),
type: 'boolean',
};
adduserschema.properties['roles'] = {
title: this.props.intl.formatMessage(messages.addUserFormRolesTitle),
type: 'array',
choices: this.canAssignAdd(isUserManager).map((role) => [
role.id,
role.title,
]),
noValueOption: false,
};
adduserschema.properties['groups'] = {
title: this.props.intl.formatMessage(messages.addUserGroupNameTitle),
type: 'array',
choices: this.props.groups
.filter((group) => canAssignGroup(isUserManager, group))
.map((group) => [group.id, group.id]),
noValueOption: false,
};
if (
adduserschema.fieldsets &&
adduserschema.fieldsets.length > 0 &&
!adduserschema.fieldsets[0]['fields'].includes('username')
) {
adduserschema.fieldsets[0]['fields'] = adduserschema.fieldsets[0][
'fields'
].concat([
'username',
'password',
'sendPasswordReset',
'roles',
'groups',
]);
}
}
return (
<Container className="users-control-panel">
<Helmet title={this.props.intl.formatMessage(messages.users)} />
<div className="container">
<Confirm
open={this.state.showDelete}
header={this.props.intl.formatMessage(
messages.deleteUserConfirmTitle,
)}
content={
<div className="content">
<Dimmer active={this.props?.deleteRequest?.loading}>
<Loader>
<FormattedMessage id="Loading" defaultMessage="Loading." />
</Loader>
</Dimmer>
<ul className="content">
<FormattedMessage
id="Do you really want to delete the user {username}?"
defaultMessage="Do you really want to delete the user {username}?"
values={{
username: <b>{usernameToDelete}</b>,
}}
/>
</ul>
</div>
}
onCancel={this.onDeleteCancel}
onConfirm={this.onDeleteOk}
size={null}
/>
{this.props?.userschema?.loaded && this.state.showAddUser ? (
<ModalForm
open={this.state.showAddUser}
className="modal"
onSubmit={this.onAddUserSubmit}
submitError={this.state.addUserError}
onCancel={() =>
this.setState({ showAddUser: false, addUserError: undefined })
}
title={this.props.intl.formatMessage(messages.addUserFormTitle)}
loading={this.props.createRequest.loading}
schema={adduserschema}
/>
) : null}
</div>
<Segment.Group raised>
<Segment className="primary">
<FormattedMessage id="Users" defaultMessage="Users" />
</Segment>
<Segment secondary>
<FormattedMessage
id="Note that roles set here apply directly to a user. The symbol{plone_svg}indicates a role inherited from membership in a group."
defaultMessage="Note that roles set here apply directly to a user. The symbol{plone_svg}indicates a role inherited from membership in a group."
values={{
plone_svg: (
<Icon
name={ploneSVG}
size="20px"
color="#007EB1"
title={'plone-svg'}
/>
),
}}
/>
</Segment>
<Segment>
<Form onSubmit={this.onSearch}>
<Form.Field>
<Input
name="SearchableText"
action={{
icon: 'search',
loading: this.state.isLoading,
disabled: this.state.isLoading,
}}
placeholder={this.props.intl.formatMessage(
messages.searchUsers,
)}
onChange={this.onChangeSearch}
id="user-search-input"
/>
</Form.Field>
</Form>
</Segment>
<Form>
{((this.props.many_users && this.state.entries.length > 0) ||
!this.props.many_users) && (
<Table padded striped attached unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>
<FormattedMessage
id="User name"
defaultMessage="User name"
/>
</Table.HeaderCell>
{this.props.roles.map((role) => (
<Table.HeaderCell key={role.id}>
{role.title}
</Table.HeaderCell>
))}
<Table.HeaderCell>
<FormattedMessage id="Actions" defaultMessage="Actions" />
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body data-user="users">
{this.state.entries
.slice(
this.state.currentPage * 10,
this.state.pageSize * (this.state.currentPage + 1),
)
.map((user) => (
<RenderUsers
key={user.id}
onDelete={this.delete}
roles={this.props.roles}
user={user}
updateUser={this.updateUserRole}
inheritedRole={this.props.inheritedRole}
userschema={this.props.userschema}
listUsers={this.props.listUsers}
isUserManager={isUserManager}
/>
))}
</Table.Body>
</Table>
)}
{this.state.entries.length === 0 && this.state.search && (
<Segment>
{this.props.intl.formatMessage(messages.userSearchNoResults)}
</Segment>
)}
<div className="contents-pagination">
<Pagination
current={this.state.currentPage}
total={Math.ceil(
this.state.entries?.length / this.state.pageSize,
)}
onChangePage={this.onChangePage}
/>
</div>
</Form>
</Segment.Group>
{this.state.isClient &&
createPortal(
<Toolbar
pathname={this.props.pathname}
hideDefaultViewButtons
inner={
<>
<Button
id="toolbar-save"
className="save"
aria-label={this.props.intl.formatMessage(messages.save)}
onClick={this.updateUserRoleSubmit}
loading={this.props.createRequest.loading}
>
<Icon
name={saveSVG}
className="circled"
size="30px"
title={this.props.intl.formatMessage(messages.save)}
/>
</Button>
<Link to="/controlpanel" className="cancel">
<Icon
name={clearSVG}
className="circled"
aria-label={this.props.intl.formatMessage(
messages.cancel,
)}
size="30px"
title={this.props.intl.formatMessage(messages.cancel)}
/>
</Link>
<Button
id="toolbar-add"
aria-label={this.props.intl.formatMessage(
messages.addUserButtonTitle,
)}
onClick={() => {
this.setState({ showAddUser: true });
}}
loading={this.props.createRequest.loading}
>
<Icon
name={addUserSvg}
size="45px"
color="#826A6A"
title={this.props.intl.formatMessage(
messages.addUserButtonTitle,
)}
/>
</Button>
</>
}
/>,
document.getElementById('toolbar'),
)}
</Container>
);
}
}
export default compose(
injectIntl,
connect(
(state, props) => ({
roles: state.roles.roles,
users: state.users.users,
user: state.users.user,
userId: state.userSession.token
? jwtDecode(state.userSession.token).sub
: '',
groups: state.groups.groups,
many_users: state.controlpanels?.controlpanel?.data?.many_users,
many_groups: state.controlpanels?.controlpanel?.data?.many_groups,
description: state.description,
pathname: props.location.pathname,
deleteRequest: state.users.delete,
createRequest: state.users.create,
loadRolesRequest: state.roles,
inheritedRole: state.authRole.authenticatedRole,
userschema: state.userschema,
controlPanelData: state.controlpanels?.controlpanel,
}),
(dispatch) =>
bindActionCreators(
{
listRoles,
listUsers,
listGroups,
getControlpanel,
deleteUser,
createUser,
updateUser,
updateGroup,
getUserSchema,
getUser,
},
dispatch,
),
),
)(UsersControlpanel);