diff --git a/packages/component-invite/config/default.js b/packages/component-invite/config/default.js index 6da7762b05361ae72d6841628918d2864fa1e9f6..350b9de7761a91153fff07c37b29603005d36c33 100644 --- a/packages/component-invite/config/default.js +++ b/packages/component-invite/config/default.js @@ -8,7 +8,7 @@ module.exports = { 'http://localhost:3000/invite', }, roles: { - global: ['admin', 'editorInChief', 'author'], + global: ['admin', 'editorInChief', 'author', 'handlingEditor'], collection: ['handlingEditor', 'reviewer'], inviteRights: { admin: ['admin', 'editorInChief', 'author'], diff --git a/packages/component-invite/src/controllers/assignCollectionRole.js b/packages/component-invite/src/controllers/assignCollectionRole.js index 64a852869fc05d22bcb22caa3ae4f087b83bc83e..e810e941147c908584f9b5ec5daaf72aa9c2ad5a 100644 --- a/packages/component-invite/src/controllers/assignCollectionRole.js +++ b/packages/component-invite/src/controllers/assignCollectionRole.js @@ -4,6 +4,7 @@ const helpers = require('../helpers/helpers') const teamHelper = require('../helpers/Team') const mailService = require('pubsweet-component-mail-service') const inviteHelper = require('../helpers/Invitation') +const collHelper = require('../helpers/Collection') const configRoles = config.get('roles') @@ -53,8 +54,9 @@ module.exports = async ( } } + let collection try { - await models.Collection.find(collectionId) + collection = await models.Collection.find(collectionId) } catch (e) { const notFoundError = await helpers.handleNotFoundError(e, 'collection') return res.status(notFoundError.status).json({ @@ -65,7 +67,7 @@ module.exports = async ( try { let user = await models.User.findByEmail(email) - let team = teamHelper.getTeamByGroupAndCollection( + let team = await teamHelper.getTeamByGroupAndCollection( collectionId, role, models.Team, @@ -77,11 +79,13 @@ module.exports = async ( collectionId, role, ) + user = await models.User.findByEmail(email) + } else { + user.teams = user.teams || [] + user.teams.push(team.id) + user = await user.save() } - // getting the updated user from the DB - creating a team also updates the user - user = await models.User.findByEmail(email) - if (user.invitations === undefined) { user = await inviteHelper.setupInvitation( user, @@ -89,6 +93,7 @@ module.exports = async ( collectionId, team.id, ) + await collHelper.addAssignedPeople(collection, user, role) } else { const matchingInvitation = inviteHelper.getMatchingInvitation( user.invitations, @@ -102,6 +107,7 @@ module.exports = async ( collectionId, team.id, ) + await collHelper.addAssignedPeople(collection, user, role) } } diff --git a/packages/component-invite/src/helpers/Collection.js b/packages/component-invite/src/helpers/Collection.js new file mode 100644 index 0000000000000000000000000000000000000000..d11dc8b7011c1fe50c5b9b7aae4d288a16eae402 --- /dev/null +++ b/packages/component-invite/src/helpers/Collection.js @@ -0,0 +1,22 @@ +const invitationHelper = require('./Invitation') + +module.exports = { + addAssignedPeople: async (collection, user, role) => { + collection.assignedPeople = collection.assignedPeople || [] + const matchingInvitation = invitationHelper.getMatchingInvitation( + user.invitations, + collection.id, + role, + ) + const assignedPerson = { + id: user.id, + email: user.email, + name: `${user.firstName} ${user.lastName}`, + role, + hasAnswer: matchingInvitation.hasAnswer, + isAccepted: matchingInvitation.isAccepted, + } + collection.assignedPeople.push(assignedPerson) + await collection.save() + }, +} diff --git a/packages/component-invite/src/helpers/Team.js b/packages/component-invite/src/helpers/Team.js index 298e0c50834ae3296dda91d19d08de5962df22bc..39bbca5152ab564cbc4e5ac53de18953a962de4a 100644 --- a/packages/component-invite/src/helpers/Team.js +++ b/packages/component-invite/src/helpers/Team.js @@ -74,7 +74,7 @@ const setupEiCTeams = async (models, user) => { const setupManuscriptTeam = async (models, user, collectionId, role) => { const teams = await models.Team.all() - user.teams = [] + user.teams = user.teams || [] const filteredTeams = teams.filter( team => team.group === role && @@ -89,12 +89,16 @@ const setupManuscriptTeam = async (models, user, collectionId, role) => { try { team = await team.updateProperties(team) team = await team.save() + user.teams.push(team.id) + await user.save() return team } catch (e) { logger.error(e) } } else { const team = await createNewTeam(collectionId, role, user.id, models.Team) + user.teams.push(team.id) + await user.save() return team } } diff --git a/packages/component-invite/src/helpers/helpers.js b/packages/component-invite/src/helpers/helpers.js index 3be4674565565f5f29588f90cfb27f3f6172aa28..0628217ee67a8b0d6697869a7cf7beb491dfc8af 100644 --- a/packages/component-invite/src/helpers/helpers.js +++ b/packages/component-invite/src/helpers/helpers.js @@ -84,7 +84,7 @@ const createNewUser = async ( UserModel, role, ) => { - const username = uuid.v4().slice(0, 8) + const username = email const password = uuid.v4() const userBody = { username, diff --git a/packages/component-invite/src/tests/postInvite.test.js b/packages/component-invite/src/tests/postInvite.test.js index 23612de26242e7355af9d1ce286b4adaed942933..e350f5c33c485da065b6d4e029e9aa59fd06ad3c 100644 --- a/packages/component-invite/src/tests/postInvite.test.js +++ b/packages/component-invite/src/tests/postInvite.test.js @@ -179,6 +179,7 @@ describe('Post invite route handler', () => { const data = JSON.parse(res._getData()) expect(data.email).toEqual(body.email) expect(data.invitations[0].collectionId).toEqual(req.params.collectionId) + expect(standardCollection.assignedPeople).toHaveLength(1) }) it('should return success when the handlingEditor invites a reviewer with a collection', async () => { const body = { diff --git a/packages/component-modal/src/components/Modal.js b/packages/component-modal/src/components/Modal.js index b16774202a19629f580e8fe050200b9c1fa2ba8c..4015782fef9d60d7ecde1e2d19efeb83628036d5 100644 --- a/packages/component-modal/src/components/Modal.js +++ b/packages/component-modal/src/components/Modal.js @@ -40,5 +40,5 @@ const ModalRoot = styled.div` justify-content: center; background-color: ${({ overlayColor }) => overlayColor || 'rgba(0, 0, 0, 0.8)'}; - z-index: ${({ theme }) => theme.modalIndex}; + /* z-index: ${({ theme }) => theme.modalIndex}; */ ` diff --git a/packages/component-modal/src/components/withModal.js b/packages/component-modal/src/components/withModal.js index 1fb9a2261709bcaa4470e3b68bd1969b467b68f0..1d5c786c7b6026264442f30a9ab52ecb59c0a7fa 100644 --- a/packages/component-modal/src/components/withModal.js +++ b/packages/component-modal/src/components/withModal.js @@ -1,28 +1,29 @@ import React from 'react' +import { omit } from 'lodash' import { connect } from 'react-redux' import Modal from './Modal' -import { setModalVisibility } from '../redux/modal' +import { showModal, hideModal } from '../redux/modal' const mapState = state => ({ - modalVisible: state.modal.visible, + modalsVisibility: omit(state.modal, 'props'), modalProps: state.modal.props, }) -const mapDispatch = dispatch => ({ - hideModal: () => dispatch(setModalVisibility(false)), - showModal: (modalProps = {}) => - dispatch(setModalVisibility(true, modalProps)), +const mapDispatch = modalKey => (dispatch, propss) => ({ + hideModal: () => dispatch(hideModal()), + showModal: (modalProps = {}) => dispatch(showModal(modalKey, modalProps)), }) const withModal = ({ + modalKey, modalComponent: Component, overlayColor, }) => WrappedComponent => - connect(mapState, mapDispatch)( - ({ modalVisible, modalProps, hideModal, ...rest }) => ( - <div> - {modalVisible && ( + connect(mapState, mapDispatch(modalKey))( + ({ modalsVisibility, modalProps, hideModal, ...rest }) => ( + <React.Fragment> + {modalsVisibility[modalKey] && ( <Modal {...modalProps} component={Component} @@ -31,7 +32,7 @@ const withModal = ({ /> )} <WrappedComponent hideModal={hideModal} {...rest} /> - </div> + </React.Fragment> ), ) diff --git a/packages/component-modal/src/redux/modal.js b/packages/component-modal/src/redux/modal.js index 8730d759836c52c0d062c0ba9cdd735cdb6e2e6a..cba9f707d60c3001c123abad56ced39270e1821e 100644 --- a/packages/component-modal/src/redux/modal.js +++ b/packages/component-modal/src/redux/modal.js @@ -1,20 +1,33 @@ const initialState = { - visible: false, props: {}, } -const SET_MODAL_VISIBILTY = 'modal/SET_MODAL_VISIBILTY' +const SHOW_MODAL = 'modal/SHOW_MODAL' +const HIDE_MODAL = 'modal/HIDE_MODAL' -export const setModalVisibility = (visible, props = {}) => ({ - type: SET_MODAL_VISIBILTY, +export const showModal = (modalKey, props = {}) => ({ + type: SHOW_MODAL, payload: { - visible, + modalKey, props, }, }) +export const hideModal = () => ({ + type: HIDE_MODAL, +}) + +export const getModalVisibility = (state, modalKey) => state[modalKey] + export default (state = initialState, action = {}) => { switch (action.type) { + case SHOW_MODAL: + return { + [action.payload.modalKey]: true, + props: action.payload.props, + } + case HIDE_MODAL: + return initialState default: return state } diff --git a/packages/components-faraday/package.json b/packages/components-faraday/package.json index e9712a020a7ae437039e2530163351a2d6129862..d262d3dbd0b4a9ea1d1ed71a68fcddd47efccf55 100644 --- a/packages/components-faraday/package.json +++ b/packages/components-faraday/package.json @@ -6,7 +6,6 @@ "dependencies": { "@pubsweet/ui": "^3.1.0", "moment": "^2.20.1", - "portal-modal": "^1.0.3", "prop-types": "^15.5.10", "react": "^16.1.0", "react-dnd": "^2.5.4", diff --git a/packages/components-faraday/src/components/Admin/AddUserForm.js b/packages/components-faraday/src/components/Admin/AddUserForm.js index df8e96e0561b86d1748a70932322d396b0c5668b..ba6dd5a179422703e0cefb0b43794d79a28525b1 100644 --- a/packages/components-faraday/src/components/Admin/AddUserForm.js +++ b/packages/components-faraday/src/components/Admin/AddUserForm.js @@ -13,7 +13,7 @@ const emailValidator = value => const AddUserForm = ({ roles, journal, error }) => { const roleOptions = roles.filter(r => - ['editorInChief', 'author', 'admin'].includes(r.value), + ['editorInChief', 'author', 'admin', 'handlingEditor'].includes(r.value), ) return ( <div> diff --git a/packages/components-faraday/src/components/Dashboard/AssignEditor.js b/packages/components-faraday/src/components/Dashboard/AssignEditor.js new file mode 100644 index 0000000000000000000000000000000000000000..4fca2fe52927497f5f0d595dcc4c7a5ea37b77c0 --- /dev/null +++ b/packages/components-faraday/src/components/Dashboard/AssignEditor.js @@ -0,0 +1,42 @@ +import React from 'react' +import styled, { css } from 'styled-components' +import { Button, th } from '@pubsweet/ui' +import { compose, withHandlers } from 'recompose' +import { withModal } from 'pubsweet-component-modal/src/components' + +import HEModal from './AssignHEModal' + +const AssignEditor = ({ assign }) => ( + <ActionButtons onClick={assign}>ASSIGN</ActionButtons> +) + +export default compose( + withModal({ + modalKey: 'assignHEmodal', + modalComponent: HEModal, + }), + withHandlers({ + assign: ({ showModal, collectionId }) => () => { + showModal({ + collectionId, + }) + }, + }), +)(AssignEditor) + +// #region styled-components +const defaultText = css` + color: ${th('colorText')}; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const ActionButtons = styled(Button)` + ${defaultText}; + align-items: center; + background-color: ${th('colorPrimary')}; + color: ${th('colorTextReverse')}; + text-align: center; + height: calc(${th('subGridUnit')}*5); +` +// #endregion diff --git a/packages/components-faraday/src/components/Dashboard/AssignHEModal.js b/packages/components-faraday/src/components/Dashboard/AssignHEModal.js new file mode 100644 index 0000000000000000000000000000000000000000..86e6d1438c6334a18508077cd7bbcf56b1ff03f9 --- /dev/null +++ b/packages/components-faraday/src/components/Dashboard/AssignHEModal.js @@ -0,0 +1,165 @@ +/* eslint react/prefer-stateless-function: 0 */ + +import React from 'react' +import { th } from '@pubsweet/ui' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import styled from 'styled-components' + +import { handlingEditors, assignHandlingEditor } from '../../redux/editors' + +class AssignHEModal extends React.Component { + state = { + searchInput: '', + } + + changeInput = e => { + this.setState({ searchInput: e.target.value }) + } + + filterEditors = editors => { + const { searchInput } = this.state + return editors.filter(({ firstName, lastName, email }) => + `${firstName} ${lastName} ${email}` + .toLowerCase() + .includes(searchInput.trim()), + ) + } + + assignEditor = email => () => { + const { assignHandlingEditor, collectionId, hideModal } = this.props + assignHandlingEditor(email, collectionId).then(hideModal) + } + + render() { + const { searchInput } = this.state + const { editors } = this.props + const filteredEditors = this.filterEditors(editors) + return ( + <RootModal> + <button onClick={this.props.hideModal}>CLOSE</button> + <ModalTitle>Assign Handling Editor</ModalTitle> + <ModalHeader> + <span>HANDLING EDITORS</span> + <SearchInput + onChange={this.changeInput} + type="text" + value={searchInput} + /> + </ModalHeader> + <ScrollContainer> + <ModalContent> + {filteredEditors.map(({ firstName, lastName, email }, index) => ( + <SuggestedEditor + isLast={index === filteredEditors.length - 1} + key={email} + > + <EditorDetails> + <span>{`${firstName} ${lastName}`}</span> + <span>{email}</span> + </EditorDetails> + <AssignButton onClick={this.assignEditor(email)}> + ASSIGN + </AssignButton> + </SuggestedEditor> + ))} + </ModalContent> + </ScrollContainer> + </RootModal> + ) + } +} + +export default compose( + connect( + state => ({ + editors: handlingEditors(state), + }), + { assignHandlingEditor }, + ), +)(AssignHEModal) + +// #region styled-components +const EditorDetails = styled.div` + display: flex; + flex-direction: column; +` +const SuggestedEditor = styled.div` + align-items: center; + border: ${th('borderDefault')}; + border-bottom: ${({ isLast }) => (isLast ? th('borderDefault') : 'none')}; + display: flex; + justify-content: space-between; + padding: ${th('subGridUnit')}; + height: calc(${th('gridUnit')} * 2); + + &:hover { + background-color: ${th('colorSecondary')}; + } +` + +const AssignButton = styled.button` + align-items: center; + color: ${th('colorTextReverse')}; + background-color: ${th('colorPrimary')}; + display: flex; + justify-content: center; + height: ${th('gridUnit')}; + width: calc(${th('gridUnit')} * 4); + opacity: 0; + + ${SuggestedEditor}:hover & { + opacity: 1; + } +` + +const RootModal = styled.div` + align-items: center; + background-color: ${th('colorBackgroundHue')}; + display: flex; + flex-direction: column; + justify-content: flex-start; + height: calc(${th('gridUnit')} * 18); + padding: calc(${th('subGridUnit')} * 8) calc(${th('subGridUnit')} * 6); + width: calc(${th('gridUnit')} * 24); +` + +const ModalTitle = styled.span` + color: ${th('colorPrimary')}; + font-size: ${th('fontSizeHeading4')}; + font-family: ${th('fontHeading')}; + margin-bottom: calc(${th('subGridUnit')} * 5); +` + +const ModalHeader = styled.div` + align-self: stretch; + display: flex; + flex-direction: column; + + & span { + color: ${th('colorPrimary')}; + font-size: ${th('fontSizeBase')}; + margin-bottom: ${th('subGridUnit')}; + } +` + +const SearchInput = styled.input` + border: 4px solid gray; + height: calc(${th('subGridUnit')} * 5); + padding: ${th('subGridUnit')}; +` + +const ScrollContainer = styled.div` + align-self: stretch; + flex: 1; + overflow: auto; + border: ${th('borderDefault')}; +` + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + overflow: auto; + padding: calc(${th('subGridUnit')} * 2) calc(${th('subGridUnit')} * 3); +` +// #endregion diff --git a/packages/components-faraday/src/components/Dashboard/Dashboard.js b/packages/components-faraday/src/components/Dashboard/Dashboard.js index d609c746c5ef4b87d24acef2dfa036727ea9bbe6..6945dbc1de252a9bf328d38051f97f032b2d39fa 100644 --- a/packages/components-faraday/src/components/Dashboard/Dashboard.js +++ b/packages/components-faraday/src/components/Dashboard/Dashboard.js @@ -1,11 +1,8 @@ import React from 'react' -import { get } from 'lodash' import { Button } from '@pubsweet/ui' import styled from 'styled-components' import { compose, withHandlers } from 'recompose' -import { withModal } from 'pubsweet-component-modal/src/components' -import AbstractModal from './AbstractModal' import DashboardItems from './DashboardItems' import DashboardFilters from './DashboardFilters' @@ -49,9 +46,6 @@ const Dashboard = ({ ) export default compose( - withModal({ - modalComponent: AbstractModal, - }), withHandlers({ showAbstractModal: ({ showModal }) => metadata => () => { showModal({ @@ -65,16 +59,11 @@ export default compose( currentUser, dashboard, filterItems, - }) => () => { - const userItems = get(currentUser, 'admin') - ? dashboard.all - : dashboard.owner - - return filterItems(userItems).sort((a, b) => { + }) => () => + filterItems(dashboard.all).sort((a, b) => { if (sortOrder === 'newest') return a.created - b.created < 0 return a.created - b.created > 0 - }) - }, + }), }), )(Dashboard) diff --git a/packages/components-faraday/src/components/Dashboard/DashboardCard.js b/packages/components-faraday/src/components/Dashboard/DashboardCard.js index af8cd34a48f317f033810fc98d953c6a40cb9404..9401acd55ba87aac3e577ecc444865d973eb433d 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardCard.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardCard.js @@ -1,17 +1,13 @@ import React from 'react' import { get } from 'lodash' import PropTypes from 'prop-types' +import { compose, getContext } from 'recompose' import { Button, Icon, th } from '@pubsweet/ui' -import styled, { css } from 'styled-components' -import { compose, getContext, withHandlers } from 'recompose' -import { - withModal, - ConfirmationModal, -} from 'pubsweet-component-modal/src/components' - -import { parseVersion, parseJournalIssue } from './utils' +import styled, { css, withTheme } from 'styled-components' import ZipFiles from './ZipFiles' +import { parseVersion, parseJournalIssue } from './utils' +import HandlingEditorActions from './HandlingEditorActions' const DashboardCard = ({ deleteProject, @@ -21,6 +17,7 @@ const DashboardCard = ({ showAbstractModal, journal, cancelSubmission, + theme, ...rest }) => { const { submitted, title, type } = parseVersion(version) @@ -87,7 +84,7 @@ const DashboardCard = ({ } > Details - <Icon color="#667080">chevron-right</Icon> + <Icon color={theme.colorPrimary}>chevron-right</Icon> </Details> ) : ( <Details @@ -118,49 +115,33 @@ const DashboardCard = ({ arr, ) => ( <Author key={email}> - {isSubmitting && <AuthorStatus>SA</AuthorStatus>} - {isCorresponding && - !isSubmitting && <AuthorStatus>CA</AuthorStatus>} <AuthorName> {firstName} {middleName} {lastName} </AuthorName> + {isSubmitting && <AuthorStatus>SA</AuthorStatus>} + {isCorresponding && + !isSubmitting && <AuthorStatus>CA</AuthorStatus>} {arr.length - 1 === index ? '' : ','} </Author> ), )} </AuthorList> </Top> + <Bottom> + <LeftDetails flex="5"> + <HEText>Handling Editor</HEText> + <HandlingEditorActions project={project} /> + </LeftDetails> + </Bottom> </DetailsView> )} </Card> ) : null } -export default compose( - getContext({ journal: PropTypes.object }), - withModal({ - modalComponent: ConfirmationModal, - }), - withHandlers({ - cancelSubmission: ({ - showModal, - deleteProject, - project, - hideModal, - }) => () => { - const modalConfig = { - onConfirm: () => { - deleteProject(project) - hideModal() - }, - dismissable: false, - title: 'Are you sure you want to delete the manuscript?', - subtitle: 'This operation cannot be undone!', - } - showModal(modalConfig) - }, - }), -)(DashboardCard) +export default compose(getContext({ journal: PropTypes.object }), withTheme)( + DashboardCard, +) // #region styled-components const defaultText = css` @@ -191,6 +172,7 @@ const AuthorStatus = styled.span` text-align: center; text-transform: uppercase; padding: 0 2px; + margin-left: 4px; ` const ActionButtons = styled(Button)` @@ -226,7 +208,7 @@ const DetailsView = styled.div` align-items: center; border-top: ${th('borderDefault')}; display: flex; - flex-direction: row; + flex-direction: column; justify-content: space-between; width: 100%; ` @@ -322,9 +304,11 @@ const Status = styled.div` border: ${th('borderDefault')}; ${defaultText}; font-weight: bold; - padding: 0.2em 0.5em; + padding: 0 0.5em; text-align: left; - text-transform: uppercase; + text-transform: capitalize; + line-height: 1.5; + color: ${th('colorPrimary')}; ` const DateField = styled.span` @@ -332,4 +316,10 @@ const DateField = styled.span` margin: 0 ${th('subGridUnit')}; text-align: left; ` + +const HEText = styled.div` + ${defaultText}; + text-transform: uppercase; +` + // #endregion diff --git a/packages/components-faraday/src/components/Dashboard/DashboardPage.js b/packages/components-faraday/src/components/Dashboard/DashboardPage.js index f183c434e5ebc1f5bc323565b7b3b6480634c1f5..1e59c3c6684e7284307aeb139d0d7ee0f780971f 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardPage.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardPage.js @@ -10,7 +10,7 @@ import { newestFirst, selectCurrentUser } from 'xpub-selectors' import { createDraftSubmission } from 'pubsweet-component-wizard/src/redux/conversion' import Dashboard from './Dashboard' - +import { getHandlingEditors } from '../../redux/editors' import withFilters from './withFilters' export default compose( @@ -18,6 +18,7 @@ export default compose( actions.getCollections(), actions.getTeams(), actions.getUsers(), + getHandlingEditors(), ]), connect( state => { diff --git a/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js b/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js new file mode 100644 index 0000000000000000000000000000000000000000..c9a60e77aee3acdcdf1bd9ebbd390a808121c61b --- /dev/null +++ b/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js @@ -0,0 +1,78 @@ +import React from 'react' +import { get, head } from 'lodash' +import { Icon, th } from '@pubsweet/ui' +import styled, { css, withTheme } from 'styled-components' +import { compose, withHandlers } from 'recompose' +import AssignEditor from './AssignEditor' + +const HandlingEditorActions = ({ project, theme, getHandlingEditor }) => { + const handlingEditor = getHandlingEditor() + return ( + <Root> + <HEActions> + {handlingEditor ? ( + <HEActions> + <HEName>{get(handlingEditor, 'name')}</HEName> + {!handlingEditor.hasAnswer && ( + <HEActions> + <Icon color={theme.colorPrimary}>refresh-cw</Icon> + <Icon color={theme.colorPrimary}>x-circle</Icon> + </HEActions> + )} + </HEActions> + ) : ( + <AssignEditor collectionId={project.id} /> + )} + </HEActions> + </Root> + ) +} + +export default compose( + withTheme, + withHandlers({ + getHandlingEditor: ({ project }) => () => { + const assignedEditors = get(project, 'assignedPeople') + if (assignedEditors && assignedEditors.length) { + return head( + assignedEditors.filter( + editor => + !editor.hasAnswer || (editor.hasAnswer && editor.isAccepted), + ), + ) + } + return null + }, + }), +)(HandlingEditorActions) + +// #region styled-components +const defaultText = css` + color: ${th('colorText')}; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseSmall')}; +` + +const Root = styled.div` + margin-left: ${th('gridUnit')}; +` + +const HEName = styled.div`` + +const HEActions = styled.div` + ${defaultText}; + text-transform: uppercase; + display: flex; + align-items: center; + cursor: pointer; + margin-left: ${th('subGridUnit')}; + span { + margin-left: ${th('subGridUnit')}; + &:hover { + svg { + opacity: 0.8; + } + } + } +` +// #endregion diff --git a/packages/components-faraday/src/index.js b/packages/components-faraday/src/index.js index 10dad084777d68e581bd79b6f900ae98d64ab1cc..babba89a0463742f8865121f3975cb48a8b5fda4 100644 --- a/packages/components-faraday/src/index.js +++ b/packages/components-faraday/src/index.js @@ -4,7 +4,7 @@ module.exports = { reducers: { authors: () => require('./redux/authors').default, files: () => require('./redux/files').default, - modal: () => require('./redux/modal').default, + editors: () => require('./redux/editors').default, }, }, } diff --git a/packages/components-faraday/src/redux/editors.js b/packages/components-faraday/src/redux/editors.js new file mode 100644 index 0000000000000000000000000000000000000000..77fee22d73b2f328a8e718a8a028d30b74ad3955 --- /dev/null +++ b/packages/components-faraday/src/redux/editors.js @@ -0,0 +1,39 @@ +import { get, create, remove } from 'pubsweet-client/src/helpers/api' + +const SET_HANDLING_EDITORS = 'SET_HANDLING_EDITORS' + +const setHandlingEditors = editors => ({ + type: SET_HANDLING_EDITORS, + editors, +}) + +export const handlingEditors = state => state.editors + +export const getHandlingEditors = () => dispatch => + get(`/users?handlingEditor=true`).then(res => { + dispatch(setHandlingEditors(res.users)) + }) + +export const assignHandlingEditor = ( + email, + collectionId, + resend = false, +) => dispatch => + create(`/users/invite/${collectionId}`, { + email, + role: 'handlingEditor', + resend, + }) + +export const revokeHandlingEditor = (collectionId, userId) => dispatch => + remove(`/collections/${collectionId}/users/${userId}?role=handlingEditor`) + +const initialState = [] +export default (state = initialState, action = {}) => { + switch (action.type) { + case SET_HANDLING_EDITORS: + return action.editors + default: + return state + } +} diff --git a/packages/components-faraday/src/redux/index.js b/packages/components-faraday/src/redux/index.js index bc48f2ff567e17a64eb57928d1222dc3382b2ab1..cf03e87f32321c663eebead2e2354f09687aa323 100644 --- a/packages/components-faraday/src/redux/index.js +++ b/packages/components-faraday/src/redux/index.js @@ -1,2 +1,2 @@ -export { default as modal } from './modal' export { default as authors } from './authors' +export { default as editors } from './editors' diff --git a/packages/components-faraday/src/redux/modal.js b/packages/components-faraday/src/redux/modal.js deleted file mode 100644 index ba8a4ba264804681f2a9a070665e76db97c49b5d..0000000000000000000000000000000000000000 --- a/packages/components-faraday/src/redux/modal.js +++ /dev/null @@ -1,3 +0,0 @@ -import { modalReducer } from 'portal-modal' - -export default modalReducer diff --git a/packages/xpub-faraday/config/authsome.js b/packages/xpub-faraday/config/authsome.js index 17447be2eaa63ecd8dc407122b67625b8676f1a1..978d988cac126e8195a1f26fc9eacc8cdce4f954 100644 --- a/packages/xpub-faraday/config/authsome.js +++ b/packages/xpub-faraday/config/authsome.js @@ -1,3 +1,200 @@ -module.exports = (user, operation, project, version) => - // console.log({ user, operation, project, version }) - true // TODO +const get = require('lodash/get') +const pickBy = require('lodash/pickBy') +const omit = require('lodash/omit') + +async function teamPermissions(user, operation, object, context) { + const heTeamsProm = user.teams + .map(async teamId => { + const team = await context.models.Team.find(teamId) + if (team.teamType.permissions === 'handlingEditor') { + return team + } + return null + }) + .filter(Boolean) + + const heTeams = await Promise.all(heTeamsProm) + const heCollections = heTeams.map(team => team.object.id) + + if (heCollections.length > 0) { + return { + filter: collections => { + if (collections.length > 0) { + const correctColl = collections.filter(coll => + heCollections.includes(coll.id), + ) + return correctColl + } + return collections + }, + } + } + + return {} +} + +function unauthenticatedUser(operation, object) { + // Public/unauthenticated users can GET /collections, filtered by 'published' + if (operation === 'GET' && object && object.path === '/collections') { + return { + filter: collections => + collections.filter(collection => collection.published), + } + } + + // Public/unauthenticated users can GET /collections/:id/fragments, filtered by 'published' + if ( + operation === 'GET' && + object && + object.path === '/collections/:id/fragments' + ) { + return { + filter: fragments => fragments.filter(fragment => fragment.published), + } + } + + // and filtered individual collection's properties: id, title, source, content, owners + if (operation === 'GET' && object && object.type === 'collection') { + if (object.published) { + return { + filter: collection => + pickBy(collection, (_, key) => + ['id', 'title', 'owners'].includes(key), + ), + } + } + } + + if (operation === 'GET' && object && object.type === 'fragment') { + if (object.published) { + return { + filter: fragment => + pickBy(fragment, (_, key) => + ['id', 'title', 'source', 'presentation', 'owners'].includes(key), + ), + } + } + } + + return false +} + +async function authenticatedUser(user, operation, object, context) { + // Allow the authenticated user to POST a collection (but not with a 'filtered' property) + // if (operation === 'POST' && object.path === '/collections') { + // return { + // filter: collection => omit(collection, 'filtered'), + // } + // } + + // Allow the authenticated user to GET collections they own + if (operation === 'GET' && object === '/collections/') { + return { + filter: collection => collection.owners.includes(user.id), + } + } + + // Allow owners of a collection to GET its teams, e.g. + // GET /api/collections/1/teams + if (operation === 'GET' && get(object, 'path') === '/teams') { + const collectionId = get(object, 'params.collectionId') + if (collectionId) { + const collection = await context.models.Collection.find(collectionId) + if (collection.owners.includes(user.id)) { + return true + } + } + } + + if ( + operation === 'GET' && + get(object, 'type') === 'team' && + get(object, 'object.type') === 'collection' + ) { + const collection = await context.models.Collection.find( + get(object, 'object.id'), + ) + if (collection.owners.includes(user.id)) { + return true + } + } + + // Advanced example + // Allow authenticated users to create a team based around a collection + // if they are one of the owners of this collection + if (['POST', 'PATCH'].includes(operation) && get(object, 'type') === 'team') { + if (get(object, 'object.type') === 'collection') { + const collection = await context.models.Collection.find( + get(object, 'object.id'), + ) + if (collection.owners.includes(user.id)) { + return true + } + } + } + + if (user.teams.length !== 0) { + const permissions = await teamPermissions(user, operation, object, context) + + if (permissions) { + return permissions + } + } + + if (get(object, 'type') === 'fragment') { + const fragment = object + + if (fragment.owners.includes(user.id)) { + return true + } + } + + if (get(object, 'type') === 'collection') { + const collection = object + + // Owner user + if (collection.owners.includes(user.id)) { + if (['GET', 'DELETE'].includes(operation)) { + return true + } + + // Only allow filtered updating (mirroring filtered creation) for non-admin users) + if (operation === 'PATCH') { + return { + filter: collection => omit(collection, 'filtered'), + } + } + } + } + + // A user can GET, DELETE and PATCH itself + if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { + if (['GET', 'DELETE', 'PATCH'].includes(operation)) { + return true + } + } + // If no individual permissions exist (above), fallback to unauthenticated + // user's permission + return unauthenticatedUser(operation, object) +} + +const authsomeMode = async (userId, operation, object, context) => { + if (!userId) { + return unauthenticatedUser(operation, object) + } + + // It's up to us to retrieve the relevant models for our + // authorization/authsome mode, e.g. + const user = await context.models.User.find(userId) + + // Admins and editor in chiefs can do anything + if (user && (user.admin === true || user.editorInChief === true)) return true + + if (user) { + return authenticatedUser(user, operation, object, context) + } + + return false +} + +module.exports = authsomeMode diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index 550c6caa6c0ae14212271b1f57d95e431079363c..5064bd743ded07f3d8fcdf3f977e39c77a9b3dfa 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -9,6 +9,7 @@ module.exports = { status: Joi.string(), reviewers: Joi.array(), customId: Joi.string(), + assignedPeople: Joi.array(), }, fragment: [ {