From 628a25213435948e0f2d48ec256710957e28446f Mon Sep 17 00:00:00 2001 From: wesleybl Date: Fri, 29 Sep 2023 15:23:04 -0300 Subject: [PATCH 01/10] Do not display options for Site Administrator to create, modify, or delete Manager users --- .../core/controlpanels/groups-controlpanel.js | 1 + packages/volto/news/5244.feature | 1 + .../Groups/GroupsControlpanel.jsx | 38 +++++++++++-- .../Groups/GroupsControlpanel.test.jsx | 12 +++++ .../Controlpanels/Groups/RenderGroups.jsx | 33 ++++++++---- .../Groups/RenderGroups.test.jsx | 1 + .../__snapshots__/RenderGroups.test.jsx.snap | 3 ++ .../Controlpanels/Users/RenderUsers.jsx | 51 ++++++++++-------- .../Controlpanels/Users/RenderUsers.test.jsx | 7 ++- .../Users/UserGroupMembershipListing.jsx | 36 ++++++++++--- .../Controlpanels/Users/UsersControlpanel.jsx | 54 +++++++++++++++++-- .../Users/UsersControlpanel.test.jsx | 8 +++ .../__snapshots__/RenderUsers.test.jsx.snap | 3 ++ packages/volto/src/helpers/User/User.js | 29 ++++++++++ packages/volto/src/helpers/index.js | 7 ++- 15 files changed, 235 insertions(+), 49 deletions(-) create mode 100644 packages/volto/news/5244.feature diff --git a/packages/volto/cypress/tests/core/controlpanels/groups-controlpanel.js b/packages/volto/cypress/tests/core/controlpanels/groups-controlpanel.js index 62512f1a0e..955229c828 100644 --- a/packages/volto/cypress/tests/core/controlpanels/groups-controlpanel.js +++ b/packages/volto/cypress/tests/core/controlpanels/groups-controlpanel.js @@ -52,6 +52,7 @@ describe('Groups Control Panel Test', () => { // select first group with name, delete it and search if its exists or not! cy.get('div[role="listbox"]').first().click(); + cy.get('div[role="option"]').should('be.visible'); cy.get('div[role="option"]').first().click(); cy.contains('Delete Group'); cy.get('button.ui.primary.button').should('have.text', 'OK').click(); diff --git a/packages/volto/news/5244.feature b/packages/volto/news/5244.feature new file mode 100644 index 0000000000..5565451e9f --- /dev/null +++ b/packages/volto/news/5244.feature @@ -0,0 +1 @@ +Do not display options for Site Administrator to create, modify, or delete Manager users. @wesleybl diff --git a/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.jsx b/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.jsx index 95505c9030..fb4a4aac0e 100644 --- a/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.jsx @@ -10,7 +10,9 @@ import { listRoles, updateGroup, authenticatedRole, + getUser, } from '@plone/volto/actions'; +import jwtDecode from 'jwt-decode'; import { Icon, ModalForm, @@ -21,7 +23,12 @@ import { Error, } from '@plone/volto/components'; import { Link } from 'react-router-dom'; -import { Helmet, messages } from '@plone/volto/helpers'; +import { + Helmet, + messages, + isManager, + canAssignRole, +} from '@plone/volto/helpers'; 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'; @@ -75,6 +82,19 @@ class GroupsControlpanel extends Component { groupname: 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, }; /** @@ -118,6 +138,7 @@ class GroupsControlpanel extends Component { groupEntries: this.props.groups, }); } + await this.props.getUser(this.props.userId); }; /** * Component did mount @@ -375,6 +396,8 @@ class GroupsControlpanel extends Component { ? this.state.groupToDelete.id : ''; + const isUserManager = isManager(this.props.user); + return ( @@ -460,10 +483,9 @@ class GroupsControlpanel extends Component { messages.addGroupsFormRolesTitle, ), type: 'array', - choices: this.props.roles.map((role) => [ - role.id, - role.title, - ]), + choices: this.props.roles + .filter((role) => canAssignRole(isUserManager, role)) + .map((role) => [role.id, role.title]), noValueOption: false, description: '', }, @@ -553,6 +575,7 @@ class GroupsControlpanel extends Component { group={group} updateGroups={this.updateGroupRole} inheritedRole={this.state.authenticatedRole} + isUserManager={isUserManager} /> ))} @@ -640,6 +663,10 @@ export default compose( injectIntl, connect( (state, props) => ({ + user: state.users.user, + userId: state.userSession.token + ? jwtDecode(state.userSession.token).sub + : '', roles: state.roles.roles, groups: state.groups.groups, description: state.description, @@ -661,6 +688,7 @@ export default compose( createGroup, updateGroup, authenticatedRole, + getUser, }, dispatch, ), diff --git a/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.test.jsx b/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.test.jsx index f6bf548d90..f520c37a07 100644 --- a/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.test.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Groups/GroupsControlpanel.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; +import jwt from 'jsonwebtoken'; import GroupsControlpanel from './GroupsControlpanel'; @@ -12,6 +13,17 @@ jest.mock('react-portal', () => ({ describe('UsersControlpanel', () => { it('renders a user control component', () => { const store = mockStore({ + userSession: { + token: jwt.sign({ sub: 'john' }, 'secret'), + }, + users: { + users: [], + create: { loading: false }, + user: { + roles: ['Manager'], + '@id': 'admin', + }, + }, roles: { roles: [] }, groups: { groups: [], diff --git a/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.jsx b/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.jsx index 816ad593da..f707003ed1 100644 --- a/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.jsx @@ -9,6 +9,7 @@ import { Dropdown, Table, Checkbox } from 'semantic-ui-react'; import trashSVG from '@plone/volto/icons/delete.svg'; import ploneSVG from '@plone/volto/icons/plone.svg'; import { Icon } from '@plone/volto/components'; +import { canAssignRole } from '@plone/volto/helpers'; /** * UsersControlpanelGroups class. @@ -38,6 +39,7 @@ class RenderGroups extends Component { ).isRequired, inheritedRole: PropTypes.array, onDelete: PropTypes.func.isRequired, + isUserManager: PropTypes.bool.isRequired, }; /** @@ -69,6 +71,12 @@ class RenderGroups extends Component { isAuthGroup = (roleId) => { return this.props.inheritedRole.includes(roleId); }; + + canDeleteGroup() { + if (this.props.isUserManager) return true; + return !this.props.group.roles.includes('Manager'); + } + /** * Render method. * @method render @@ -98,22 +106,25 @@ class RenderGroups extends Component { } onChange={this.onChange} value={`${this.props.group.id}&role=${role.id}`} + disabled={!canAssignRole(this.props.isUserManager, role)} /> )} ))} - - - - - - - - + {this.canDeleteGroup() && ( + + + + + + + + + )} ); diff --git a/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.test.jsx b/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.test.jsx index 81f917be36..089b7f0946 100644 --- a/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.test.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Groups/RenderGroups.test.jsx @@ -49,6 +49,7 @@ describe('UsersControlpanelGroups', () => { group={testGroups} roles={testRoles} onDelete={() => {}} + isUserManager={true} /> , ); diff --git a/packages/volto/src/components/manage/Controlpanels/Groups/__snapshots__/RenderGroups.test.jsx.snap b/packages/volto/src/components/manage/Controlpanels/Groups/__snapshots__/RenderGroups.test.jsx.snap index 80e8c78c57..da7a333536 100644 --- a/packages/volto/src/components/manage/Controlpanels/Groups/__snapshots__/RenderGroups.test.jsx.snap +++ b/packages/volto/src/components/manage/Controlpanels/Groups/__snapshots__/RenderGroups.test.jsx.snap @@ -22,6 +22,7 @@ exports[`UsersControlpanelGroups renders a UsersControlpanelGroups component 1`] )} ))} - - - {this.props.userschema && ( + {this.canDeleteUser() && ( + + + {this.props.userschema && ( + { + this.onClickEdit({ formData: this.props.user }); + }} + value={this.props.user['@id']} + > + + + + )} { - this.onClickEdit({ formData: this.props.user }); - }} + id="delete-user-button" + onClick={this.props.onDelete} value={this.props.user['@id']} > - - + + - )} - - - - - - + + + )} {Object.keys(this.state.user).length > 0 && this.props.userschema.loaded && ( diff --git a/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx b/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx index e2be0df42c..45e954e4f9 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx @@ -47,7 +47,12 @@ describe('UsersControlpanelUser', () => { }); const component = renderer.create( - {}} /> + {}} + isUserManager={true} + /> , ); const json = component.toJSON(); diff --git a/packages/volto/src/components/manage/Controlpanels/Users/UserGroupMembershipListing.jsx b/packages/volto/src/components/manage/Controlpanels/Users/UserGroupMembershipListing.jsx index 7c52c347f2..d12dbf10de 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/UserGroupMembershipListing.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Users/UserGroupMembershipListing.jsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from 'react'; import { cloneDeep, uniqBy } from 'lodash'; import { useIntl } from 'react-intl'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector, useDispatch, shallowEqual } from 'react-redux'; +import jwtDecode from 'jwt-decode'; import { toast } from 'react-toastify'; import { Button, Checkbox } from 'semantic-ui-react'; -import { messages } from '@plone/volto/helpers'; -import { listGroups } from '@plone/volto/actions'; +import { messages, isManager, canAssingGroup } from '@plone/volto/helpers'; +import { listGroups, getUser } from '@plone/volto/actions'; import { Icon, Toast } from '@plone/volto/components'; import { updateGroup, listUsers } from '@plone/volto/actions'; @@ -25,6 +26,16 @@ const ListingTemplate = ({ const pageSize = 25; const [userLimit, setUserLimit] = useState(pageSize); + const token = useSelector((state) => state.userSession.token, shallowEqual); + const user = useSelector((state) => state.users.user); + const userId = token ? jwtDecode(token).sub : ''; + + useEffect(() => { + dispatch(getUser(userId)); + }, [dispatch, userId]); + + const isUserManager = isManager(user); + // y axis let items = useSelector((state) => state.users.users); let show_users = @@ -51,12 +62,17 @@ const ListingTemplate = ({ // x axis let groups = useSelector((state) => state.groups.groups); + + const getRoles = (group_id) => { + return groups.find((group) => group.id === group_id)?.roles || []; + }; + let show_matrix_options = !many_groups || (many_groups && query_group.length > 1) || groups_filter.length > 0 || add_joined_groups; - let matrix_options; // list of Objects (value, label) + let matrix_options; // list of Objects (value, label, roles) if (show_matrix_options) { matrix_options = !many_groups || (many_groups && query_group.length > 1) @@ -90,6 +106,10 @@ const ListingTemplate = ({ } return 0; }); + matrix_options = matrix_options.map((matrix_option) => ({ + ...matrix_option, + roles: getRoles(matrix_option.value), + })); } else { matrix_options = []; } @@ -126,7 +146,7 @@ const ListingTemplate = ({ }, }), ) - .then((resp) => { + .then(() => { singleClick && dispatch( listUsers({ @@ -209,13 +229,14 @@ const ListingTemplate = ({ + onChange={(_event, { checked }) => onSelectAllHandler( matrix_option.value, items.map((el) => el.id), checked, ) } + disabled={!canAssingGroup(isUserManager, matrix_option)} /> ))} @@ -251,13 +272,14 @@ const ListingTemplate = ({ checked={item.groups?.items ?.map((el) => el.id) .includes(matrix_option.value)} - onChange={(event, { checked }) => { + onChange={(_event, { checked }) => { onSelectOptionHandler( { y: matrix_option.value, x: item.id }, checked, true, ); }} + disabled={!canAssingGroup(isUserManager, matrix_option)} /> ))} diff --git a/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx b/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx index a8f8e6687b..a961ea260e 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx @@ -12,7 +12,9 @@ import { updateUser, updateGroup, getUserSchema, + getUser, } from '@plone/volto/actions'; +import jwtDecode from 'jwt-decode'; import { Icon, ModalForm, @@ -23,7 +25,12 @@ import { Error, } from '@plone/volto/components'; import { Link } from 'react-router-dom'; -import { Helmet, messages } from '@plone/volto/helpers'; +import { + Helmet, + messages, + isManager, + canAssingGroup, +} from '@plone/volto/helpers'; 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'; @@ -77,6 +84,19 @@ class UsersControlpanel extends Component { 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, }; /** @@ -124,6 +144,7 @@ class UsersControlpanel extends Component { }); } await this.props.getUserSchema(); + await this.props.getUser(this.props.userId); }; // Because username field needs to be disabled if email login is enabled! @@ -406,6 +427,20 @@ class UsersControlpanel extends Component { } } + /** + * 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 @@ -426,7 +461,9 @@ class UsersControlpanel extends Component { // 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), ); @@ -454,13 +491,18 @@ class UsersControlpanel extends Component { adduserschema.properties['roles'] = { title: this.props.intl.formatMessage(messages.addUserFormRolesTitle), type: 'array', - choices: this.props.roles.map((role) => [role.id, role.title]), + 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.map((group) => [group.id, group.id]), + choices: this.props.groups + .filter((group) => canAssingGroup(isUserManager, group)) + .map((group) => [group.id, group.id]), noValueOption: false, }; if ( @@ -598,6 +640,7 @@ class UsersControlpanel extends Component { inheritedRole={this.props.inheritedRole} userschema={this.props.userschema} listUsers={this.props.listUsers} + isUserManager={isUserManager} /> ))} @@ -686,6 +729,10 @@ export default compose( (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, @@ -710,6 +757,7 @@ export default compose( updateUser, updateGroup, getUserSchema, + getUser, }, dispatch, ), diff --git a/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx b/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx index 49286f9c15..3a2fecc71c 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; +import jwt from 'jsonwebtoken'; import UsersControlpanel from './UsersControlpanel'; @@ -12,10 +13,17 @@ jest.mock('react-portal', () => ({ describe('UsersControlpanel', () => { it('renders a user control component', () => { const store = mockStore({ + userSession: { + token: jwt.sign({ sub: 'john' }, 'secret'), + }, roles: { roles: [] }, users: { users: [], create: { loading: false }, + user: { + roles: ['Manager'], + '@id': 'admin', + }, }, groups: { groups: [], diff --git a/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap b/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap index 150057097e..eddc934a08 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap +++ b/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap @@ -26,6 +26,7 @@ exports[`UsersControlpanelUser renders a UsersControlpanelUser component 1`] = ` 0; } + +/** + * Checks if the user is Manager. + * @method isManager + * @returns {boolean} + */ +export function isManager(user) { + return userHasRoles(user, ['Manager']); +} + +/** + * Checks if the user can assing group. + * @method canAssingGroup + * @returns {boolean} + */ +export function canAssingGroup(isManager, group) { + if (isManager) return true; + return !group.roles.includes('Manager'); +} + +/** + * Checks if the user can assign role. + * @method canDeleteGroup + * @returns {boolean} + */ +export function canAssignRole(isManager, role) { + if (isManager) return true; + return role.id !== 'Manager'; +} diff --git a/packages/volto/src/helpers/index.js b/packages/volto/src/helpers/index.js index e67c383e4e..1655f2356a 100644 --- a/packages/volto/src/helpers/index.js +++ b/packages/volto/src/helpers/index.js @@ -115,7 +115,12 @@ export { composeSchema, } from './Extensions'; export { asyncConnect } from './AsyncConnect'; -export { userHasRoles } from './User/User'; +export { + userHasRoles, + isManager, + canAssingGroup, + canAssignRole, +} from './User/User'; // export { injectLazyLibs } from './Loadable/Loadable'; export { useDetectClickOutside } from './Utils/useDetectClickOutside'; export { useEvent } from './Utils/useEvent'; From 24a002cf7e89d7f27123403937f9c0bf9ec71803 Mon Sep 17 00:00:00 2001 From: wesleybl Date: Thu, 22 Feb 2024 11:25:28 -0300 Subject: [PATCH 02/10] Add UsersControlpanel test when the user is not a Manager --- .../Controlpanels/Users/RenderUsers.test.jsx | 21 +++ .../__snapshots__/RenderUsers.test.jsx.snap | 134 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx b/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx index 45e954e4f9..4653d6281a 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx +++ b/packages/volto/src/components/manage/Controlpanels/Users/RenderUsers.test.jsx @@ -58,4 +58,25 @@ describe('UsersControlpanelUser', () => { const json = component.toJSON(); expect(json).toMatchSnapshot(); }); + + it('renders a UsersControlpanelUser component with options disabled if not allowed', () => { + const store = mockStore({ + intl: { + locale: 'en', + messages: {}, + }, + }); + const component = renderer.create( + + {}} + isUserManager={false} + /> + , + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + }); }); diff --git a/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap b/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap index eddc934a08..ebdde31d51 100644 --- a/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap +++ b/packages/volto/src/components/manage/Controlpanels/Users/__snapshots__/RenderUsers.test.jsx.snap @@ -133,3 +133,137 @@ exports[`UsersControlpanelUser renders a UsersControlpanelUser component 1`] = ` `; + +exports[`UsersControlpanelUser renders a UsersControlpanelUser component with options disabled if not allowed 1`] = ` + + + Test User + + ( + testuser + ) + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+