diff --git a/packages/component-admin/index.js b/packages/component-admin/index.js deleted file mode 100644 index f569b273abd8938c1a205c421dff09fc32e824ec..0000000000000000000000000000000000000000 --- a/packages/component-admin/index.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - backend: () => app => require('./src/User')(app), -} diff --git a/packages/component-admin/src/User.js b/packages/component-admin/src/User.js deleted file mode 100644 index f72f01649615103c983a34407c02ebd314a4596e..0000000000000000000000000000000000000000 --- a/packages/component-admin/src/User.js +++ /dev/null @@ -1,150 +0,0 @@ -const bodyParser = require('body-parser') -const logger = require('@pubsweet/logger') -const uuid = require('uuid') -const crypto = require('crypto') -const mailService = require('pubsweet-component-mail-service') - -const User = app => { - app.use(bodyParser.json()) - const authBearer = app.locals.passport.authenticate('bearer', { - session: false, - }) - app.post('/api/admin/users', authBearer, async (req, res) => { - const reqUser = await app.locals.models.User.find(req.user) - if (reqUser.admin !== true) { - res.status(403).json({ error: 'Unauthorized' }) - logger.error('unauthorized request') - return - } - const { email, role } = req.body - if (email === undefined || role === undefined) { - res.status(400).json({ error: 'all parameters are required' }) - logger.error('some parameters are missing') - return - } - - try { - const user = await app.locals.models.User.findByEmail(email) - if (user) { - res.status(400).json({ error: 'User already exists' }) - logger.error('admin tried to invite existing user') - return - } - } catch (e) { - if (e.name === 'NotFoundError') { - const userBody = { - username: uuid.v4().slice(0, 7), - email, - password: uuid.v4(), - roles: { role }, - passwordResetToken: crypto.randomBytes(32).toString('hex'), - isConfirmed: false, - } - let newUser = new app.locals.models.User(userBody) - newUser = await newUser.save() - let emailType - switch (newUser.roles.role) { - case 'editorInChief': - emailType = 'invite-editor-in-chief' - break - case 'handlingEditor': - emailType = 'invite-handling-editor' - break - case 'reviewer': - emailType = 'invite-reviewer' - break - default: - break - } - await mailService.setupEmail( - newUser.email, - emailType, - newUser.passwordResetToken, - ) - } - } - - res.status(204).json() - }) - app.post( - '/api/admin/users/password-reset', - bodyParser.json(), - async (req, res) => { - const { - token, - password, - email, - firstName, - lastName, - username, - middleName, - affiliation, - position, - title, - } = req.body - - if ( - !checkForUndefinedParams( - token, - password, - email, - firstName, - lastName, - username, - ) - ) { - res.status(400).json({ error: 'missing required params' }) - return - } - - const updateFields = { - password, - firstName, - lastName, - username, - middleName, - affiliation, - position, - title, - isConfirmed: true, - } - - try { - const user = await app.locals.models.User.findByEmail(email) - if (user) { - if (token !== user.passwordResetToken) { - res.status(400).json({ error: 'invalid request' }) - logger.error('admin pw reset tokens do not match') - return - } - - let newUser = Object.assign(user, updateFields, user) - delete newUser.passwordResetToken - - newUser = await newUser.save() - res.status(200).json(newUser) - } - } catch (e) { - if (e.name === 'NotFoundError') { - res.status(404).json({ error: 'user not found' }) - logger.error('admin pw reset on non-existing user') - } else if (e.name === 'ValidationError') { - res.status(400).json({ error: e.details[0].message }) - logger.error('admin pw reset validation error') - } - res.status(400).json({ error: e }) - logger.error(e) - } - }, - ) -} - -const checkForUndefinedParams = (...params) => { - if (params.includes(undefined)) { - return false - } - - return true -} - -module.exports = User diff --git a/packages/component-admin/.gitignore b/packages/component-invite/.gitignore similarity index 100% rename from packages/component-admin/.gitignore rename to packages/component-invite/.gitignore diff --git a/packages/component-admin/README.md b/packages/component-invite/README.md similarity index 100% rename from packages/component-admin/README.md rename to packages/component-invite/README.md diff --git a/packages/component-invite/index.js b/packages/component-invite/index.js new file mode 100644 index 0000000000000000000000000000000000000000..1bdf5faede2f8361802cef49db4e1efb763998ed --- /dev/null +++ b/packages/component-invite/index.js @@ -0,0 +1,3 @@ +module.exports = { + backend: () => app => require('./src/Invite')(app), +} diff --git a/packages/component-admin/package.json b/packages/component-invite/package.json similarity index 84% rename from packages/component-admin/package.json rename to packages/component-invite/package.json index 12bc8f99650bf87df42887c5d9d350090b7d8771..8f205cd7a469fc874f282ecc7f1918cc030df327 100644 --- a/packages/component-admin/package.json +++ b/packages/component-invite/package.json @@ -1,7 +1,7 @@ { - "name": "pubsweet-component-admin", + "name": "pubsweet-component-invite", "version": "0.0.1", - "description": "admin component for pubsweet", + "description": "invite component for pubsweet", "license": "MIT", "files": [ "src" diff --git a/packages/component-invite/src/Invite.js b/packages/component-invite/src/Invite.js new file mode 100644 index 0000000000000000000000000000000000000000..3c92f595065f42746f8c4101d19c846a985b1562 --- /dev/null +++ b/packages/component-invite/src/Invite.js @@ -0,0 +1,218 @@ +const bodyParser = require('body-parser') +const logger = require('@pubsweet/logger') +const uuid = require('uuid') +const crypto = require('crypto') +const mailService = require('pubsweet-component-mail-service') +const get = require('lodash/get') + +const Invite = app => { + app.use(bodyParser.json()) + const authBearer = app.locals.passport.authenticate('bearer', { + session: false, + }) + app.post('/api/users/invite/:collectionId?', authBearer, async (req, res) => { + const { email, role } = req.body + if (!checkForUndefinedParams(email, role)) { + res.status(400).json({ error: 'Email and role are required' }) + logger.error('some parameters are missing') + return + } + + const collectionId = get(req, 'params.collectionId') + const reqUser = await app.locals.models.User.find(req.user) + let collection + if (collectionId) { + try { + if (role !== 'reviewer' && role !== 'handlingEditor') { + res.status(400).json({ error: 'Role does not exist for collections' }) + logger.error( + `invitation has been attempted with invalid role: ${role}`, + ) + return + } + if (reqUser.roles === undefined) { + res + .status(403) + .json({ error: 'Only HE or EiC can invite users to collection' }) + logger.error(`request user does not have any defined roles`) + return + } + if (role === 'reviewer' && !reqUser.roles.includes('handlingEditor')) { + res.status(403).json({ error: 'Only HE can invite reviewers' }) + logger.error(`incorrect role when inviting a reviewer`) + return + } else if ( + role === 'handlingEditor' && + !reqUser.roles.includes('editorInChief') + ) { + res.status(403).json({ error: 'Only EiC can invite HE' }) + logger.error(`incorrect role when inviting a handling editor`) + return + } + collection = await app.locals.models.Collection.find(collectionId) + } catch (e) { + if (e.name === 'NotFoundError') { + res.status(404).json({ error: 'Collection not found' }) + logger.error(`invalid collection id when inviting ${role}`) + return + } + + res.status(500).json({ error: 'Something went wrong' }) + logger.error(e) + return + } + } else if (role !== 'editorInChief') { + res.status(400).json({ error: 'Collection id is required' }) + logger.error('missing collection id when trying to invite reviewer/HE') + return + } else if (reqUser.admin !== true) { + res.status(403).json({ error: 'Only an admin can invite EiC' }) + logger.error('non-admin user tried to invite an EiC') + return + } + + try { + const user = await app.locals.models.User.findByEmail(email) + + if (user) { + res.status(400).json({ error: 'User already exists' }) + logger.error('admin tried to invite existing user') + return + } + } catch (e) { + if (e.name !== 'NotFoundError') { + res.status(500).json({ error: e }) + logger.error(e) + return + } + + const userBody = { + username: uuid.v4().slice(0, 7), + email, + password: uuid.v4(), + roles: [role], + passwordResetToken: crypto.randomBytes(32).toString('hex'), + isConfirmed: false, + } + let newUser = new app.locals.models.User(userBody) + newUser = await newUser.save() + + let emailType = 'invite-editor-in-chief' + if (collection) { + let permissions, group, name + switch (newUser.roles[0]) { + case 'handlingEditor': + emailType = 'invite-handling-editor' + permissions = 'editor' + group = 'editor' + name = 'Handling Editor' + break + case 'reviewer': + emailType = 'invite-reviewer' + permissions = 'reviewer' + group = 'reviewer' + name = 'Reviewer' + break + default: + break + } + + const teamBody = { + teamType: { + name: newUser.roles[0], + permissions, + }, + group, + name, + object: { + type: 'collection', + id: collection.id, + }, + members: [newUser.id], + } + const team = new app.locals.models.Team(teamBody) + await team.save() + } + + await mailService.setupEmail( + newUser.email, + emailType, + newUser.passwordResetToken, + ) + + res.status(200).json(newUser) + } + }) + app.post( + '/api/users/invite/password/reset', + bodyParser.json(), + async (req, res) => { + const { + token, + password, + email, + firstName, + lastName, + middleName, + affiliation, + position, + title, + } = req.body + + if ( + !checkForUndefinedParams(token, password, email, firstName, lastName) + ) { + res.status(400).json({ error: 'missing required params' }) + return + } + + const updateFields = { + password, + firstName, + lastName, + middleName, + affiliation, + position, + title, + isConfirmed: true, + } + + try { + const user = await app.locals.models.User.findByEmail(email) + if (user) { + if (token !== user.passwordResetToken) { + res.status(400).json({ error: 'invalid request' }) + logger.error('admin pw reset tokens do not match') + return + } + + let newUser = Object.assign(user, updateFields, user) + delete newUser.passwordResetToken + + newUser = await newUser.save() + res.status(200).json(newUser) + } + } catch (e) { + if (e.name === 'NotFoundError') { + res.status(404).json({ error: 'user not found' }) + logger.error('admin pw reset on non-existing user') + } else if (e.name === 'ValidationError') { + res.status(400).json({ error: e.details[0].message }) + logger.error('admin pw reset validation error') + } + res.status(400).json({ error: e }) + logger.error(e) + } + }, + ) +} + +const checkForUndefinedParams = (...params) => { + if (params.includes(undefined)) { + return false + } + + return true +} + +module.exports = Invite diff --git a/packages/component-mail-service/src/Mail.js b/packages/component-mail-service/src/Mail.js index 23644ec833d62036e394e8f933810fad077ae486..2b053dfdac820fd4242e9fc96c4a240f41563703 100644 --- a/packages/component-mail-service/src/Mail.js +++ b/packages/component-mail-service/src/Mail.js @@ -4,7 +4,7 @@ const querystring = require('querystring') const SES = require('pubsweet-components-aws-ses') const config = require('config') -const resetUrl = config.get('admin-reset-password.url') +const resetUrl = config.get('invite-reset-password.url') module.exports = { setupEmail: async (email, emailType, token, comment = '') => { @@ -37,14 +37,11 @@ module.exports = { }, } -const readFile = path => { - const file = fs.readFileSync(path, { encoding: 'utf-8' }, (err, file) => { +const readFile = path => + fs.readFileSync(path, { encoding: 'utf-8' }, (err, file) => { if (err) { throw err } else { return file } }) - - return file -} diff --git a/packages/components-faraday/src/components/Admin/AddEditUser.js b/packages/components-faraday/src/components/Admin/AddEditUser.js index f273f5d76533d5cbf1d5f5b3dbe709b8521f4adc..73da41f467687462474ce99641d919a682b0a6de 100644 --- a/packages/components-faraday/src/components/Admin/AddEditUser.js +++ b/packages/components-faraday/src/components/Admin/AddEditUser.js @@ -76,4 +76,6 @@ const Row = styled.div` display: flex; flex-direction: row; margin: 10px 0; + align-items: center; + justify-content: space-evenly; ` diff --git a/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js new file mode 100644 index 0000000000000000000000000000000000000000..9f4a61f1437d83927db7145d3c8d103714eddc7c --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js @@ -0,0 +1,57 @@ +import React from 'react' +import styled from 'styled-components' + +import Step0 from './SignUpStep0' +import Step1 from './SignUpStep1' + +const SignUpInvitation = ({ + journal, + email, + token, + step, + nextStep, + submitConfirmation, +}) => ( + <Root> + <Title>Add New Account Details</Title> + <Subtitle> + Your details have been pre-filled, please review and confirm before set + your password. + </Subtitle> + <Email>{email}</Email> + {step === 0 && <Step0 journal={journal} onSubmit={nextStep} />} + {step === 1 && <Step1 journal={journal} onSubmit={submitConfirmation} />} + </Root> +) + +export default SignUpInvitation + +const Root = styled.div` + max-width: 500px; + min-width: 300px; + margin: 0 auto; + display: flex; + border: 1px solid var(--color-pending); + padding: 20px; + flex-direction: column; +` + +const Title = styled.div` + font-size: 24px; + font-weight: bold; + text-align: center; + margin: 10px auto; +` +const Subtitle = styled.div` + font-size: 13px; + font-weight: normal; + text-align: center; + margin: 10px auto; +` + +const Email = styled.div` + font-size: 16px; + font-weight: normal; + text-align: center; + margin: 10px auto; +` diff --git a/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js b/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js new file mode 100644 index 0000000000000000000000000000000000000000..5a4164c173dc64ba312f4c62eb1f32e05eca47f6 --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js @@ -0,0 +1,20 @@ +import { withJournal } from 'xpub-journal' +import { compose, withState, withProps, withHandlers } from 'recompose' + +import SignUpInvitation from './SignUpInvitationForm' + +export default compose( + withJournal, + withState('step', 'changeStep', 0), + withHandlers({ + nextStep: ({ changeStep }) => () => changeStep(step => step + 1), + prevStep: ({ changeStep }) => () => changeStep(step => step - 1), + submitConfirmation: () => values => values, + }), + withProps(({ location }) => { + const params = new URLSearchParams(location.search) + const email = params.get('email') + const token = params.get('token') + return { email, token } + }), +)(SignUpInvitation) diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep0.js b/packages/components-faraday/src/components/SignUp/SignUpStep0.js new file mode 100644 index 0000000000000000000000000000000000000000..809423c82ce5bdc2d9028b39b2677a971e8b2f28 --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/SignUpStep0.js @@ -0,0 +1,87 @@ +import React from 'react' +import styled from 'styled-components' +import { reduxForm } from 'redux-form' +import { required } from 'xpub-validators' +import { Button, ValidatedField, TextField, Menu } from '@pubsweet/ui' + +const Step0 = ({ journal, handleSubmit }) => ( + <FormContainer onSubmit={handleSubmit}> + <Row> + <RowItem> + <Label> First name </Label> + <ValidatedField + component={TextField} + name="firstName" + validate={[required]} + /> + </RowItem> + <RowItem> + <Label> Affiliation </Label> + <ValidatedField component={TextField} name="affiliation" /> + </RowItem> + </Row> + <Row> + <RowItem> + <Label> Middle name </Label> + <ValidatedField component={TextField} name="middleName" /> + </RowItem> + <RowItem> + <Label> Position </Label> + <ValidatedField + component={TextField} + name="position" + validate={[required]} + /> + </RowItem> + </Row> + <Row> + <RowItem> + <Label> Last name </Label> + <ValidatedField + component={TextField} + name="lastName" + validate={[required]} + /> + </RowItem> + <RowItem> + <Label> Title </Label> + <ValidatedField + component={input => <Menu {...input} options={journal.title} />} + name="title" + validate={[required]} + /> + </RowItem> + </Row> + <Row> + <Button primary type="submit"> + CONFIRM & PROCEED TO SET PASSWORD + </Button> + </Row> + </FormContainer> +) + +export default reduxForm({ + form: 'signUpInvitation', + destroyOnUnmount: false, + forceUnregisterOnUnmount: true, +})(Step0) + +const FormContainer = styled.form`` + +const Row = styled.div` + display: flex; + flex-direction: row; + margin: 20px 0; + align-items: center; + justify-content: space-evenly; +` + +const RowItem = styled.div` + flex: 1; + margin-right: 20px; +` + +const Label = styled.div` + font-size: 14px; + text-transform: uppercase; +` diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep1.js b/packages/components-faraday/src/components/SignUp/SignUpStep1.js new file mode 100644 index 0000000000000000000000000000000000000000..53da9b98ef2dba046e25d534b5a2c6e95f6dd550 --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/SignUpStep1.js @@ -0,0 +1,51 @@ +import React from 'react' +import styled from 'styled-components' +import { reduxForm } from 'redux-form' +import { required } from 'xpub-validators' +import { Button, ValidatedField, TextField } from '@pubsweet/ui' + +const Step1 = ({ journal, handleSubmit }) => ( + <FormContainer onSubmit={handleSubmit}> + <Row> + <RowItem> + <Label> Password </Label> + <ValidatedField + component={input => <TextField {...input} type="password" />} + name="password" + validate={[required]} + /> + </RowItem> + </Row> + <Row> + <Button primary type="submit"> + CONFIRM + </Button> + </Row> + </FormContainer> +) + +export default reduxForm({ + form: 'signUpInvitation', + destroyOnUnmount: false, + forceUnregisterOnUnmount: true, +})(Step1) + +const FormContainer = styled.form`` + +const Row = styled.div` + display: flex; + flex-direction: row; + margin: 20px 0; + align-items: center; + justify-content: space-evenly; +` + +const RowItem = styled.div` + flex: 1; + margin-right: 20px; +` + +const Label = styled.div` + font-size: 14px; + text-transform: uppercase; +` diff --git a/packages/xpub-faraday/app/config/journal/index.js b/packages/xpub-faraday/app/config/journal/index.js index 874f461288cd76f59551a4018215c1ff5408c99e..e3666200803bc17b8d68f62b8695bc96b8c6d87c 100644 --- a/packages/xpub-faraday/app/config/journal/index.js +++ b/packages/xpub-faraday/app/config/journal/index.js @@ -10,3 +10,4 @@ export { default as issueTypes } from './issues-types' export { default as articleTypes } from './article-types-tbrm' export { default as articleSections } from './article-sections-tbrm' export { default as manuscriptTypes } from './manuscript-types' +export { default as title } from './title' diff --git a/packages/xpub-faraday/app/config/journal/title.js b/packages/xpub-faraday/app/config/journal/title.js new file mode 100644 index 0000000000000000000000000000000000000000..d2829fac00346ecc4782359c6e290bc2cc053f88 --- /dev/null +++ b/packages/xpub-faraday/app/config/journal/title.js @@ -0,0 +1,6 @@ +export default [ + { + label: 'Prof.', + value: 'prof', + }, +] diff --git a/packages/xpub-faraday/app/routes.js b/packages/xpub-faraday/app/routes.js index 290a73e113619d66173385177aab5484cbba5254..032d54a0b756a49422696621e168087fdc7b0287 100644 --- a/packages/xpub-faraday/app/routes.js +++ b/packages/xpub-faraday/app/routes.js @@ -19,6 +19,7 @@ import { AdminUsers, } from 'pubsweet-components-faraday/src/components/Admin' import AddEditUser from 'pubsweet-components-faraday/src/components/Admin/AddEditUser' +import SignUpInvitationPage from 'pubsweet-components-faraday/src/components/SignUp/SignUpInvitationPage' import FaradayApp from './FaradayApp' @@ -42,6 +43,7 @@ const Routes = () => ( path="/admin/users/edit/:userId" /> <PrivateRoute component={LogoutPage} exact path="/logout" /> + <PrivateRoute component={SignUpInvitationPage} exact path="/invite" /> <PrivateRoute component={Wizard} exact diff --git a/packages/xpub-faraday/config/components.json b/packages/xpub-faraday/config/components.json index d64253bf25516eebd779a3f72a6f728e37ef63c6..0219f3fda35effeebc20af6c48b484e5ed3ca2d4 100644 --- a/packages/xpub-faraday/config/components.json +++ b/packages/xpub-faraday/config/components.json @@ -7,5 +7,5 @@ "pubsweet-component-wizard", "pubsweet-components-faraday", "pubsweet-components-aws-s3", - "pubsweet-component-admin" + "pubsweet-component-invite" ] diff --git a/packages/xpub-faraday/config/default.js b/packages/xpub-faraday/config/default.js index 245f1dbe7b3d34ab7c80d7399c2d000240341f51..b5acdae84239af709cacddfc81852a9fb7d37b62 100644 --- a/packages/xpub-faraday/config/default.js +++ b/packages/xpub-faraday/config/default.js @@ -60,10 +60,10 @@ module.exports = { region: process.env.AWS_SES_REGION, sender: process.env.EMAIL_SENDER, }, - 'admin-reset-password': { + 'invite-reset-password': { url: - process.env.PUBSWEET_ADMIN_PASSWORD_RESET_URL || - 'http://localhost:3000/admin/password-reset', + process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || + 'http://localhost:3000/invite', }, publicKeys: [ 'pubsweet-client', diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index 8b98883df175275ecb57a268cf4f549d065a92c3..879eeb43eebaaf892bca5dabdc81e483456c5809 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -86,8 +86,8 @@ module.exports = { }, ], user: { - name: Joi.string(), // TODO: add "name" to the login form - roles: Joi.object(), + name: Joi.string(), + roles: Joi.array(), isConfirmed: Joi.boolean(), firstName: Joi.string().allow(''), lastName: Joi.string().allow(''), diff --git a/packages/xpub-faraday/package.json b/packages/xpub-faraday/package.json index e3b80d41b71900568e6bb0eb5d821f06df2765f7..f344bc49ec85ca3a6bcc1dff01299b0c19211e00 100644 --- a/packages/xpub-faraday/package.json +++ b/packages/xpub-faraday/package.json @@ -21,6 +21,7 @@ "prop-types": "^15.5.10", "pubsweet": "^1.1.1", "pubsweet-client": "^1.1.1", + "pubsweet-component-admin": "^0.0.1", "pubsweet-component-ink-backend": "^0.1.1", "pubsweet-component-ink-frontend": "^0.2.3", "pubsweet-component-xpub-app": "^0.0.2", @@ -29,9 +30,9 @@ "pubsweet-component-xpub-manuscript": "^0.0.2", "pubsweet-component-xpub-review": "^0.0.2", "pubsweet-component-xpub-submit": "^0.0.2", - "pubsweet-server": "1.0.5", "pubsweet-components-aws-s3": "^0.0.1", - "pubsweet-component-admin": "^0.0.1", + "pubsweet-component-invite": "^0.0.1", + "pubsweet-server": "1.0.5", "react": "^15.6.1", "react-dnd": "^2.5.4", "react-dnd-html5-backend": "^2.5.4",