UNPKG

@plone/volto

Version:
789 lines (761 loc) 23.9 kB
/** * 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);