@plone/volto
Version:
Volto
734 lines (690 loc) • 22.7 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 { useState, useEffect, useCallback } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { createPortal } from 'react-dom';
import { useSelector, useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { toast } from 'react-toastify';
import {
Confirm,
Container,
Form,
Input,
Button,
Dimmer,
Segment,
Table,
Loader,
} from 'semantic-ui-react';
/**
* UsersControlpanel functional component.
* @function UsersControlpanel
*/
const UsersControlpanel = () => {
const intl = useIntl();
const dispatch = useDispatch();
// Redux state selectors
const roles = useSelector((state) => state.roles.roles);
const users = useSelector((state) => state.users.users);
const user = useSelector((state) => state.users.user);
const userId = useSelector((state) =>
state.userSession.token ? jwtDecode(state.userSession.token).sub : '',
);
const groups = useSelector((state) => state.groups.groups);
const many_users = useSelector(
(state) => state.controlpanels?.controlpanel?.data?.many_users,
);
const location = useLocation();
const pathname = location.pathname;
const deleteRequest = useSelector((state) => state.users.delete);
const createRequest = useSelector((state) => state.users.create);
const loadRolesRequest = useSelector((state) => state.roles);
const inheritedRole = useSelector(
(state) => state.authRole.authenticatedRole,
);
const userschema = useSelector((state) => state.userschema);
const controlPanelData = useSelector(
(state) => state.controlpanels?.controlpanel,
);
// Action creators
const listRolesAction = useCallback(() => dispatch(listRoles()), [dispatch]);
const listUsersAction = useCallback(
(params) => dispatch(listUsers(params)),
[dispatch],
);
const listGroupsAction = useCallback(
() => dispatch(listGroups()),
[dispatch],
);
const getControlpanelAction = useCallback(
(panel) => dispatch(getControlpanel(panel)),
[dispatch],
);
const deleteUserAction = useCallback(
(userId) => dispatch(deleteUser(userId)),
[dispatch],
);
const updateUserAction = useCallback(
(userId, data) => dispatch(updateUser(userId, data)),
[dispatch],
);
const updateGroupAction = useCallback(
(groupId, data) => dispatch(updateGroup(groupId, data)),
[dispatch],
);
const getUserSchemaAction = useCallback(
() => dispatch(getUserSchema()),
[dispatch],
);
const getUserAction = useCallback(
(userId) => dispatch(getUser(userId)),
[dispatch],
);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showAddUser, setShowAddUser] = useState(false);
const [addUserError, setAddUserError] = useState('');
const [showDelete, setShowDelete] = useState(false);
const [userToDelete, setUserToDelete] = useState(undefined);
const [entries, setEntries] = useState([]);
const [isClient, setIsClient] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [pageSize] = useState(10);
// eslint-disable-next-line no-unused-vars
const [loginUsingEmail, setLoginUsingEmail] = useState(false); // Reserved for future use to disable username field when email login is enabled
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
await getControlpanelAction('usergroup');
await listRolesAction();
if (!many_users) {
listGroupsAction();
await listUsersAction();
setEntries(users);
}
await getUserSchemaAction();
await getUserAction(userId);
}, [
getControlpanelAction,
listRolesAction,
many_users,
listGroupsAction,
listUsersAction,
users,
getUserSchemaAction,
getUserAction,
userId,
]);
/**
* Check login using email status from security control panel
* @method checkLoginUsingEmailStatus
* @returns {undefined}
*/
const checkLoginUsingEmailStatus = useCallback(async () => {
try {
await getControlpanelAction('security');
if (controlPanelData?.data?.use_email_as_login) {
setLoginUsingEmail(controlPanelData.data.use_email_as_login);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error fetching security control panel', error);
}
}, [getControlpanelAction, controlPanelData]);
const getUserFromProps = useCallback(
(value) => {
return find(users, ['@id', value]);
},
[users],
);
/**
* Search handler
* @method onSearch
* @param {object} event Event object.
* @returns {undefined}
*/
const onSearch = useCallback(
(event) => {
event.preventDefault();
setIsLoading(true);
listUsersAction({
search: search,
})
.then(() => {
setIsLoading(false);
})
.catch((error) => {
setIsLoading(false);
// eslint-disable-next-line no-console
console.error('Error searching users', error);
});
},
[listUsersAction, search],
);
/**
* On change search handler
* @method onChangeSearch
* @param {object} event Event object.
* @returns {undefined}
*/
const onChangeSearch = (event) => {
setSearch(event.target.value);
};
/**
* Handle delete user click
* @method handleDeleteUser
* @param {object} event Event object.
* @param {string} value username.
* @returns {undefined}
*/
const handleDeleteUser = useCallback(
(event, data) => {
// Handle both formats: direct value from event target or object with value
const value =
data?.value || event?.target?.value || event?.currentTarget?.value;
if (value) {
setShowDelete(true);
setUserToDelete(getUserFromProps(value));
}
},
[getUserFromProps],
);
/**
* On delete ok
* @method onDeleteOk
* @returns {undefined}
*/
const onDeleteOk = useCallback(() => {
if (userToDelete) {
const deleteAction = deleteUserAction(userToDelete.id);
if (deleteAction && typeof deleteAction.then === 'function') {
deleteAction
.then(() => {
// Handle success
setUserToDelete(undefined);
setShowDelete(false);
// Refresh users list
listUsersAction({ search: search });
// Show success message
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.userDeleted)}
/>,
);
})
.catch((error) => {
// Handle error
// eslint-disable-next-line no-console
console.error('Error deleting user', error);
});
}
}
}, [userToDelete, deleteUserAction, listUsersAction, search, intl]);
/**
* On delete cancel
* @method onDeleteCancel
* @returns {undefined}
*/
const onDeleteCancel = () => {
setShowDelete(false);
setUserToDelete(undefined);
};
/**
*@param {object} user
*@returns {undefined}
*@memberof UsersControlpanel
*/
const addUserToGroup = useCallback(
(user) => {
const { groups: userGroups, username } = user;
userGroups.forEach((group) => {
updateGroupAction(group, {
users: {
[username]: true,
},
});
});
},
[updateGroupAction],
);
/**
* 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}
*/
const onAddUserSubmit = useCallback(
(data, callback) => {
const { groups: userGroups, sendPasswordReset, password } = data;
if (
sendPasswordReset !== undefined &&
sendPasswordReset === true &&
password !== undefined
) {
toast.error(
<Toast
error
title={intl.formatMessage(messages.error)}
content={intl.formatMessage(
messages.addUserFormPasswordAndSendPasswordTogetherNotAllowed,
)}
/>,
);
} else {
if (userGroups && userGroups.length > 0) addUserToGroup(data);
const createUserAction = createUser(data, sendPasswordReset);
dispatch(createUserAction)
.then(() => {
// Handle success
if (callback) {
callback({});
}
setShowAddUser(false);
setAddUserError(undefined);
// Refresh users list
listUsersAction({ search: search });
// Show success message
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.userCreated)}
/>,
);
})
.catch((error) => {
// Handle error
setAddUserError(
error.response?.body?.error?.message || 'Error creating user',
);
});
}
},
[intl, addUserToGroup, dispatch, search, listUsersAction],
);
/**
* Update user role
* @param {*} name
* @param {*} value
*/
const updateUserRole = useCallback(
(name, value) => {
setEntries(
map(entries, (entry) => ({
...entry,
roles:
entry.id === name && !entry.roles.includes(value)
? [...entry.roles, value]
: entry.id !== name
? entry.roles
: pull(entry.roles, value),
})),
);
},
[entries],
);
/**
* Update user role submit
* @param {*} event
*/
const updateUserRoleSubmit = useCallback(
(e) => {
e.stopPropagation();
const roleIds = roles.map((item) => item.id);
entries.forEach((item) => {
const userData = { roles: {} };
const removedRoles = difference(roleIds, item.roles);
removedRoles.forEach((role) => {
userData.roles[role] = false;
});
item.roles.forEach((role) => {
userData.roles[role] = true;
});
updateUserAction(item.id, userData);
});
toast.success(
<Toast
success
title={intl.formatMessage(messages.success)}
content={intl.formatMessage(messages.updateRoles)}
/>,
);
},
[roles, entries, updateUserAction, intl],
);
/**
* Handle Errors after createUser()
*
* @param {object} error object. Requires the property .message
* @returns {undefined}
*/
const onAddUserError = useCallback((error) => {
setAddUserError(error.response.body.error.message);
}, []);
/**
* On change page
* @method onChangePage
* @param {object} event Event object.
* @param {string} value Page value.
* @returns {undefined}
*/
const onChangePage = (event, { value }) => {
setCurrentPage(value);
};
/**
* Filters the roles a user can assign when adding a user.
* @method canAssignAdd
* @returns {arry}
*/
const canAssignAdd = useCallback(
(isManager) => {
if (isManager) return roles;
return user?.roles
? roles.filter((role) => user.roles.includes(role.id))
: [];
},
[roles, user],
);
useEffect(() => {
setIsClient(true);
fetchData();
checkLoginUsingEmailStatus();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
setEntries(users);
}, [users]);
useEffect(() => {
if (createRequest?.error && !createRequest?.loading) {
onAddUserError(createRequest.error);
}
}, [createRequest?.error, createRequest?.loading, onAddUserError]);
useEffect(() => {
if (loadRolesRequest?.error && !loadRolesRequest?.loading) {
setError(loadRolesRequest.error);
}
}, [loadRolesRequest?.error, loadRolesRequest?.loading]);
if (error) {
return <Error error={error} />;
}
const usernameToDelete = userToDelete ? 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 (userschema?.loaded) {
isUserManager = isManager(user);
adduserschema = JSON.parse(JSON.stringify(userschema?.userschema));
// Add custom form fields to the schema
adduserschema.properties.username = {
title: intl.formatMessage(messages.addUserFormUsernameTitle),
type: 'string',
description: intl.formatMessage(messages.addUserFormUsernameDescription),
};
adduserschema.properties.password = {
title: intl.formatMessage(messages.addUserFormPasswordTitle),
type: 'password',
description: intl.formatMessage(messages.addUserFormPasswordDescription),
widget: 'password',
};
adduserschema.properties.sendPasswordReset = {
title: intl.formatMessage(messages.addUserFormSendPasswordResetTitle),
type: 'boolean',
};
adduserschema.properties.roles = {
title: intl.formatMessage(messages.addUserFormRolesTitle),
type: 'array',
choices: canAssignAdd(isUserManager).map((role) => [role.id, role.title]),
noValueOption: false,
};
adduserschema.properties.groups = {
title: intl.formatMessage(messages.addUserGroupNameTitle),
type: 'array',
choices: groups
.filter((group) => canAssignGroup(isUserManager, group))
.map((group) => [group.id, group.id]),
noValueOption: false,
};
// Add custom fields to the first fieldset if they don't already exist
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={intl.formatMessage(messages.users)} />
<div className="container">
<Confirm
open={showDelete}
header={intl.formatMessage(messages.deleteUserConfirmTitle)}
content={
<div className="content">
<Dimmer active={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={onDeleteCancel}
onConfirm={onDeleteOk}
size={null}
/>
{userschema?.loaded && showAddUser ? (
<ModalForm
open={showAddUser}
className="modal"
onSubmit={onAddUserSubmit}
submitError={addUserError}
onCancel={() => {
setShowAddUser(false);
setAddUserError(undefined);
}}
title={intl.formatMessage(messages.addUserFormTitle)}
loading={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={onSearch}>
<Form.Field>
<Input
name="SearchableText"
action={{
icon: 'search',
loading: isLoading,
disabled: isLoading,
}}
placeholder={intl.formatMessage(messages.searchUsers)}
onChange={onChangeSearch}
id="user-search-input"
/>
</Form.Field>
</Form>
</Segment>
<Form>
{((many_users && entries.length > 0) || !many_users) && (
<Table padded striped attached unstackable>
<Table.Header>
<Table.Row>
<Table.HeaderCell>
<FormattedMessage
id="User name"
defaultMessage="User name"
/>
</Table.HeaderCell>
{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">
{entries
.slice(currentPage * 10, pageSize * (currentPage + 1))
.map((userItem) => (
<RenderUsers
key={userItem.id}
onDelete={handleDeleteUser}
roles={roles}
user={userItem}
updateUser={updateUserRole}
inheritedRole={inheritedRole}
userschema={userschema}
listUsers={listUsersAction}
isUserManager={isUserManager}
/>
))}
</Table.Body>
</Table>
)}
{entries.length === 0 && search && (
<Segment>
{intl.formatMessage(messages.userSearchNoResults)}
</Segment>
)}
<div className="contents-pagination">
<Pagination
current={currentPage}
total={Math.ceil(entries?.length / pageSize)}
onChangePage={onChangePage}
/>
</div>
</Form>
</Segment.Group>
{isClient &&
createPortal(
<Toolbar
pathname={pathname}
hideDefaultViewButtons
inner={
<>
<Button
id="toolbar-save"
className="save"
aria-label={intl.formatMessage(messages.save)}
onClick={updateUserRoleSubmit}
loading={createRequest?.loading}
>
<Icon
name={saveSVG}
className="circled"
size="30px"
title={intl.formatMessage(messages.save)}
/>
</Button>
<Link to="/controlpanel" className="cancel">
<Icon
name={clearSVG}
className="circled"
aria-label={intl.formatMessage(messages.cancel)}
size="30px"
title={intl.formatMessage(messages.cancel)}
/>
</Link>
<Button
id="toolbar-add"
aria-label={intl.formatMessage(messages.addUserButtonTitle)}
onClick={() => {
setShowAddUser(true);
}}
loading={createRequest?.loading}
>
<Icon
name={addUserSvg}
size="45px"
color="#826A6A"
title={intl.formatMessage(messages.addUserButtonTitle)}
/>
</Button>
</>
}
/>,
document.getElementById('toolbar'),
)}
</Container>
);
};
export default UsersControlpanel;