diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index bdb821d5a24f06eeef20210450dba2c721bbcd66..d2f4e508dffc1d7b58f9afebdb77a530d06253d1 100644 --- a/packages/component-faraday-selectors/src/index.js +++ b/packages/component-faraday-selectors/src/index.js @@ -72,13 +72,11 @@ export const canSeeReviewersReports = (state, collectionId) => { return isHE || isEiC } -export const canSeeEditorialComments = canSeeReviewersReports - -export const canMakeRevision = (state, collection) => { +export const canMakeRevision = (state, collection, fragment) => { const currentUserId = get(state, 'currentUser.user.id') return ( collection.status === 'revisionRequested' && - collection.owners.map(o => o.id).includes(currentUserId) + fragment.owners.map(o => o.id).includes(currentUserId) ) } diff --git a/packages/component-fixture-manager/src/fixtures/users.js b/packages/component-fixture-manager/src/fixtures/users.js index 29c50f9327b03f6551aca7cbcf48526446726a0f..67ecfa32f8fdea77d8bed466eb85dfd563daf1e4 100644 --- a/packages/component-fixture-manager/src/fixtures/users.js +++ b/packages/component-fixture-manager/src/fixtures/users.js @@ -100,6 +100,7 @@ const users = { save: jest.fn(() => users.author), isConfirmed: true, passwordResetToken: chance.hash(), + passwordResetTimestamp: Date.now(), teams: [authorTeamID], }, reviewer: { diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index 5b05427bc524f8766deb2ddf60b88be7d522d463..dc3513909fd9a9efb99398080319d7ce0b091bd8 100644 --- a/packages/component-helper-service/src/services/Collection.js +++ b/packages/component-helper-service/src/services/Collection.js @@ -73,8 +73,8 @@ class Collection { await this.updateStatus({ newStatus: status }) } - async updateStatusByNumberOfReviewers() { - const reviewerInvitations = this.collection.invitations.filter( + async updateStatusByNumberOfReviewers({ invitations }) { + const reviewerInvitations = invitations.filter( inv => inv.role === 'reviewer', ) if (reviewerInvitations.length === 0) diff --git a/packages/component-helper-service/src/services/Email.js b/packages/component-helper-service/src/services/Email.js index 16aea03c73f786fb8b3c3de7e54fa82e51580b57..e3ea6688ae4056120140770cb81343f139670034 100644 --- a/packages/component-helper-service/src/services/Email.js +++ b/packages/component-helper-service/src/services/Email.js @@ -26,18 +26,21 @@ class Email { } async setupReviewersEmail({ - recommendation = {}, - isSubmitted = false, - agree = false, FragmentModel, + agree = false, + isSubmitted = false, + isRevision = false, + recommendation = {}, + newFragmentId = '', }) { const { baseUrl, UserModel, collection, - parsedFragment: { recommendations, title, type, id }, + parsedFragment: { recommendations, title, type }, authors: { submittingAuthor: { firstName = '', lastName = '' } }, } = this + let { parsedFragment: { id } } = this const fragment = await FragmentModel.find(id) const fragmentHelper = new Fragment({ fragment }) const reviewerInvitations = fragmentHelper.getReviewerInvitations({ @@ -56,16 +59,19 @@ class Email { (isSubmitted && submittedReview) || (!isSubmitted && !submittedReview) if (shouldReturnUser) return UserModel.find(inv.userId) }) + let emailType = 'agreed-reviewers-after-recommendation' let emailText, subject, manuscriptType + const userHelper = new User({ UserModel }) const eic = await userHelper.getEditorInChief() - const editorName = isSubmitted + let editorName = isSubmitted ? `${eic.firstName} ${eic.lastName}` : collection.handlingEditor.name let reviewers = await Promise.all(reviewerPromises) reviewers = reviewers.filter(Boolean) + if (agree) { subject = isSubmitted ? `${collection.customId}: Manuscript Decision` @@ -76,6 +82,13 @@ class Email { emailText = 'has now been rejected' if (recommendation === 'publish') emailText = 'will now be published' } + + if (isRevision) { + emailType = 'submitting-reviewers-after-revision' + subject = `${collection.customId}: Manuscript Update` + editorName = collection.handlingEditor.name + id = newFragmentId || id + } } else { subject = `${collection.customId}: Reviewer Unassigned` manuscriptType = manuscriptTypes[type] @@ -84,18 +97,21 @@ class Email { reviewers.forEach(user => mailService.sendNotificationEmail({ - baseUrl, emailType, toEmail: user.email, meta: { + baseUrl, emailText, editorName, + collection, manuscriptType, + timestamp: Date.now(), emailSubject: subject, reviewerName: `${user.firstName} ${user.lastName}`, fragment: { title, authorName: `${firstName} ${lastName}`, + id, }, }, }), @@ -385,7 +401,7 @@ class Email { const eic = await userHelper.getEditorInChief() mailService.sendNotificationEmail({ - toEmail: eic.email, + toEmail: collection.handlingEditor.email, emailType: 'new-version-submitted', meta: { baseUrl, diff --git a/packages/component-helper-service/src/services/Fragment.js b/packages/component-helper-service/src/services/Fragment.js index d41e1c45694e0cd788a04dfebab9a1c0563132a4..6e418ee90f6d1e80bf308e16e9c89f627efeaaa4 100644 --- a/packages/component-helper-service/src/services/Fragment.js +++ b/packages/component-helper-service/src/services/Fragment.js @@ -9,6 +9,17 @@ class Fragment { this.fragment = newFragment } + static setFragmentOwners(fragment = {}, author = {}) { + const { owners = [] } = fragment + if (author.isSubmitting) { + const authorAlreadyOwner = owners.includes(author.id) + if (!authorAlreadyOwner) { + return [author.id, ...owners] + } + } + return owners + } + async getFragmentData({ handlingEditor = {} }) { const { fragment: { metadata = {}, recommendations = [], id } } = this const heRecommendation = recommendations.find( @@ -43,6 +54,7 @@ class Fragment { isCorresponding, } fragment.authors.push(author) + fragment.owners = this.constructor.setFragmentOwners(fragment, author) await fragment.save() return author diff --git a/packages/component-helper-service/src/services/Team.js b/packages/component-helper-service/src/services/Team.js index 3b208c4b68805a089041bf3f951445b2313d5d46..2e27f9a149198a158d02cb6d8e163c6a880175c5 100644 --- a/packages/component-helper-service/src/services/Team.js +++ b/packages/component-helper-service/src/services/Team.js @@ -8,7 +8,7 @@ class Team { this.collectionId = collectionId } - async createTeam({ role, userId, objectType }) { + async createTeam({ role = '', members = [], objectType = '' }) { const { fragmentId, TeamModel, collectionId } = this const objectId = objectType === 'collection' ? collectionId : fragmentId @@ -44,7 +44,7 @@ class Team { type: objectType, id: objectId, }, - members: [userId], + members, } let team = new TeamModel(teamBody) team = await team.save() @@ -79,7 +79,11 @@ class Team { logger.error(e) } } else { - const team = await this.createTeam({ role, userId: user.id, objectType }) + const team = await this.createTeam({ + role, + members: [user.id], + objectType, + }) user.teams.push(team.id) await user.save() return team @@ -136,37 +140,6 @@ class Team { await user.save() await collection.save() } - - async createOrUpdateTeamForNewVersion({ role, iterable }) { - const userIds = await Promise.all( - iterable.map(async obj => { - let { userId } = obj - if (role === 'author') { - userId = obj.id - } - return userId - }), - ) - - await Promise.all( - userIds.map(async id => { - const team = await this.getTeam({ - role, - objectType: 'fragment', - }) - if (team) { - team.members.push(id) - await team.save() - } else { - await this.createTeam({ - role, - userId: id, - objectType: 'fragment', - }) - } - }), - ) - } } module.exports = Team diff --git a/packages/component-invite/src/routes/fragmentsInvitations/delete.js b/packages/component-invite/src/routes/fragmentsInvitations/delete.js index b4da00bc268d61d124f9ef248f4ad3af04f91ecd..af74f13d32a60e8cac0c18837b23c57b384d2570 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/delete.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/delete.js @@ -53,7 +53,9 @@ module.exports = models => async (req, res) => { inv => inv.id !== invitation.id, ) - await collectionHelper.updateStatusByNumberOfReviewers() + await collectionHelper.updateStatusByNumberOfReviewers({ + invitations: fragment.invitations, + }) await teamHelper.removeTeamMember({ teamId: team.id, diff --git a/packages/component-invite/src/routes/fragmentsInvitations/patch.js b/packages/component-invite/src/routes/fragmentsInvitations/patch.js index 7ecb11d6b286fd76b69d892e5222c71d424296c3..6cb2ca49d9cd4aa8dcbac67b254963ad2edb3ee9 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/patch.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/patch.js @@ -78,7 +78,10 @@ module.exports = models => async (req, res) => { if (reason) invitation.reason = reason await fragment.save() - collectionHelper.updateStatusByNumberOfReviewers() + collectionHelper.updateStatusByNumberOfReviewers({ + invitations: fragment.invitations, + }) + emailHelper.setupReviewerDecisionEmail({ agree: false, user, diff --git a/packages/component-mail-service/src/Mail.js b/packages/component-mail-service/src/Mail.js index b8028ff25198746c2b10296af694d1fbb89c01f4..5a57a4e84b7579821c02b5448895d5b13e773130 100644 --- a/packages/component-mail-service/src/Mail.js +++ b/packages/component-mail-service/src/Mail.js @@ -5,6 +5,7 @@ const helpers = require('./helpers/helpers') const confirmSignUp = config.get('confirm-signup.url') const resetPath = config.get('invite-reset-password.url') const resetPasswordPath = config.get('invite-reviewer.url') +const forgotPath = config.get('forgot-password.url') module.exports = { sendSimpleEmail: async ({ @@ -153,6 +154,21 @@ module.exports = { replacements.url } ${replacements.buttonText}` break + case 'forgot-password': + subject = 'Forgot Password' + replacements.headline = 'You have requested a password reset.' + replacements.paragraph = + 'In order to reset your password please click on the following link:' + replacements.previewText = 'Click button to reset your password' + replacements.buttonText = 'RESET PASSWORD' + replacements.url = helpers.createUrl(dashboardUrl, forgotPath, { + email: user.email, + token: user.passwordResetToken, + }) + textBody = `${replacements.headline} ${replacements.paragraph} ${ + replacements.url + } ${replacements.buttonText}` + break default: subject = 'Welcome to Hindawi!' break @@ -188,6 +204,7 @@ module.exports = { const declineUrl = helpers.createUrl(baseUrl, resetPasswordPath, { ...queryParams, agree: false, + fragmentId: meta.fragment.id, collectionId: meta.collection.id, invitationToken: user.invitationToken, }) @@ -608,7 +625,7 @@ module.exports = { replacements.beforeAnchor = 'Previous reviewers have been automatically invited to review the manuscript again. Please visit the' replacements.afterAnchor = - 'to see the latest version and any other actions you may need to take.' + 'to see the latest version and any other actions you may need to take' replacements.signatureName = meta.eicName textBody = `${replacements.intro} ${replacements.paragraph} ${ @@ -617,6 +634,24 @@ module.exports = { replacements.signatureName }` break + case 'submitting-reviewers-after-revision': + subject = meta.emailSubject + replacements.previewText = 'A manuscript has been updated' + replacements.intro = `Dear Dr. ${meta.reviewerName}` + + replacements.paragraph = `A new version of the manuscript titled "${ + meta.fragment.title + }" by ${meta.fragment.authorName} has been submitted.` + replacements.beforeAnchor = `As you have reviewed the previous version of this manuscript, I would be grateful if you can review this revised version and submit a review report by ${helpers.getExpectedDate( + meta.timestamp, + 14, + )}. You can download the PDF of the revised version and submit your new review from the following URL:` + + replacements.signatureName = meta.editorName + textBody = `${replacements.intro} ${replacements.paragraph} ${ + replacements.beforeAnchor + } ${replacements.detailsUrl} ${replacements.signatureName}` + break default: subject = 'Hindawi Notification!' break diff --git a/packages/component-manuscript-manager/src/routes/fragments/patch.js b/packages/component-manuscript-manager/src/routes/fragments/patch.js index 7f3472a3cc103febb370b3b06731f5b2bc301ee6..af3511344a735f5a4d303bbfa6423a99eeb6293f 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragments/patch.js @@ -7,6 +7,7 @@ const { Collection, authsome: authsomeHelper, } = require('pubsweet-component-helper-service') +const union = require('lodash/union') module.exports = models => async (req, res) => { const { collectionId, fragmentId } = req.params @@ -44,48 +45,54 @@ module.exports = models => async (req, res) => { collectionId, fragmentId, }) + const userHelper = new User({ UserModel: models.User }) - fragment.authors = fragment.authors || [] - await teamHelper.createOrUpdateTeamForNewVersion({ - role: 'author', - iterable: fragment.authors, + const reviewerIds = fragment.invitations.map(inv => { + const { userId } = inv + return userId }) - const authorsTeam = await teamHelper.getTeam({ - role: 'author', + const reviewersTeam = await teamHelper.createTeam({ + role: 'reviewer', + members: reviewerIds, objectType: 'fragment', }) - const userHelper = new User({ UserModel: models.User }) - if (authorsTeam) { - fragment.authors.forEach(async author => { - await userHelper.updateUserTeams({ - userId: author.id, - teamId: authorsTeam.id, - }) - }) - } + reviewerIds.forEach(id => + userHelper.updateUserTeams({ + userId: id, + teamId: reviewersTeam.id, + }), + ) - fragment.invitations = fragment.invitations || [] - await teamHelper.createOrUpdateTeamForNewVersion({ - role: 'reviewer', - iterable: fragment.invitations, + const authorIds = fragment.authors.map(auth => { + const { id } = auth + return id }) - const reviewersTeam = await teamHelper.getTeam({ - role: 'reviewer', + let authorsTeam = await teamHelper.getTeam({ + role: 'author', objectType: 'fragment', }) - if (reviewersTeam) { - fragment.invitations.forEach(async inv => { - await userHelper.updateUserTeams({ - userId: inv.userId, - teamId: reviewersTeam.id, - }) + if (!authorsTeam) { + authorsTeam = await teamHelper.createTeam({ + role: 'author', + members: authorIds, + objectType: 'fragment', }) + } else { + authorsTeam.members = union(authorsTeam.members, authorIds) + await authorsTeam.save() } + authorIds.forEach(id => + userHelper.updateUserTeams({ + userId: id, + teamId: reviewersTeam.id, + }), + ) + const previousFragment = await models.Fragment.find( collection.fragments[fragLength - 2], ) @@ -104,22 +111,30 @@ module.exports = models => async (req, res) => { fragment.submitted = Date.now() fragment = await fragment.save() + + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const authors = await fragmentHelper.getAuthorData({ + UserModel: models.User, + }) + const email = new Email({ + authors, + collection, + parsedFragment: { ...parsedFragment, id: fragment.id }, + UserModel: models.User, + baseUrl: services.getBaseUrl(req), + }) + email.setupNewVersionSubmittedEmail() + if (heRecommendation.recommendation === 'major') { - const fragmentHelper = new Fragment({ fragment }) - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor: collection.handlingEditor, - }) - const authors = await fragmentHelper.getAuthorData({ - UserModel: models.User, - }) - const email = new Email({ - authors, - collection, - parsedFragment, - UserModel: models.User, - baseUrl: services.getBaseUrl(req), + email.setupReviewersEmail({ + agree: true, + isRevision: true, + isSubmitted: true, + FragmentModel: models.Fragment, + newFragmentId: fragment.id, }) - email.setupNewVersionSubmittedEmail() } return res.status(200).json(fragment) diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js index f4b9a4cfdd1c798837bc7e724f60175446e24160..524276fb2d35b692a586cad981fcf7e38ab6b8a9 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js @@ -71,7 +71,7 @@ module.exports = models => async (req, res) => { reviewerName: `${user.firstName} ${user.lastName}`, }) - if (!['pendingApproval', 'revisionRequested'].includes(collection.status)) + if (['underReview'].includes(collection.status)) collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) } await fragment.save() diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index 5e7bd4c017e862900b89638464578577eefbfe9d..ce3745ce0117574d4688b99c38a3480e9a14c183 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -26,8 +26,6 @@ const ManuscriptLayout = ({ history, currentUser, editorInChief, - updateManuscript, - canSeeEditorialComments, editorialRecommendations, project = {}, version = {}, @@ -55,14 +53,13 @@ const ManuscriptLayout = ({ /> <ManuscriptDetails fragment={version} /> <ReviewsAndReports project={project} version={version} /> - {canSeeEditorialComments && - editorialRecommendations.length > 0 && ( - <EditorialComments - editorInChief={editorInChief} - project={project} - recommendations={editorialRecommendations} - /> - )} + {editorialRecommendations.length > 0 && ( + <EditorialComments + editorInChief={editorInChief} + project={project} + recommendations={editorialRecommendations} + /> + )} </Container> <SideBar flex={1}> <SideBarActions project={project} version={version} /> diff --git a/packages/component-manuscript/src/components/ManuscriptPage.js b/packages/component-manuscript/src/components/ManuscriptPage.js index 3fb1ac2723b45721be06ad679fd98edb4ceae33e..a832022fdb722c6e9fe0dd077639eed86e449147 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -32,7 +32,6 @@ import { import ManuscriptLayout from './ManuscriptLayout' import { parseSearchParams, redirectToError } from './utils' -import { canSeeEditorialComments } from '../../../component-faraday-selectors' export default compose( setDisplayName('ManuscriptPage'), @@ -41,10 +40,7 @@ export default compose( withState('editorInChief', 'setEiC', 'N/A'), ConnectPage(({ match }) => [ actions.getCollection({ id: match.params.project }), - actions.getFragment( - { id: match.params.project }, - { id: match.params.version }, - ), + actions.getFragments({ id: match.params.project }), ]), connect( (state, { match }) => ({ @@ -57,10 +53,6 @@ export default compose( state, match.params.version, ), - canSeeEditorialComments: canSeeEditorialComments( - state, - match.params.project, - ), }), { replace, diff --git a/packages/component-manuscript/src/components/ReviewsAndReports.js b/packages/component-manuscript/src/components/ReviewsAndReports.js index 126be81551c3ebf04077dd977e7fea0fda791e63..80e3883cefa80fe5607502fb7920559a042c2cc5 100644 --- a/packages/component-manuscript/src/components/ReviewsAndReports.js +++ b/packages/component-manuscript/src/components/ReviewsAndReports.js @@ -12,7 +12,7 @@ import { getCollectionReviewers, selectFetchingReviewers, } from 'pubsweet-components-faraday/src/redux/reviewers' -import { selectRecommendations } from 'pubsweet-components-faraday/src/redux/recommendations' +import { selectReviewRecommendations } from 'pubsweet-components-faraday/src/redux/recommendations' import { Tabs, Expandable } from '../molecules' import { ReviewReportCard, ReviewerReportForm, ReviewReportsList } from './' @@ -37,7 +37,6 @@ const getTabSections = (collectionId, reviewers, recommendations = []) => [ ] const ReviewsAndReports = ({ - report, project, version, isAuthor, @@ -45,11 +44,7 @@ const ReviewsAndReports = ({ mappedReviewers, mappedRecommendations, canSeeReviewersReports, - // reviewerRecommendation, - // - review = {}, - reviewers = [], recommendations = [], }) => ( <Fragment> @@ -110,7 +105,7 @@ export default compose( fetchingReviewers: selectFetchingReviewers(state), isReviewer: currentUserIsReviewer(state, version.id), isAuthor: currentUserIsAuthor(state, version.id), - recommendations: selectRecommendations(state, version.id), + recommendations: selectReviewRecommendations(state, version.id), canSeeReviewersReports: canSeeReviewersReports(state, project.id), }), { getCollectionReviewers }, diff --git a/packages/component-manuscript/src/components/SideBarActions.js b/packages/component-manuscript/src/components/SideBarActions.js index c85422badd0f82227aef0f2b234819eccdfbc0f2..76a9369e78aecb0fca7ef61a44ae14629345773a 100644 --- a/packages/component-manuscript/src/components/SideBarActions.js +++ b/packages/component-manuscript/src/components/SideBarActions.js @@ -61,9 +61,9 @@ const SideBarActions = ({ export default compose( withRouter, connect( - (state, { project }) => ({ + (state, { project, version }) => ({ canMakeDecision: canMakeDecision(state, project), - canMakeRevision: canMakeRevision(state, project), + canMakeRevision: canMakeRevision(state, project, version), canMakeRecommendation: canMakeRecommendation(state, project), }), (dispatch, { project, version, history }) => ({ diff --git a/packages/component-manuscript/src/components/utils.js b/packages/component-manuscript/src/components/utils.js index f3aefdd957a6371e2e4b5d46f90fe900b20c85c6..5b2221a561a185987d824271e9dac2fc7fa6b926 100644 --- a/packages/component-manuscript/src/components/utils.js +++ b/packages/component-manuscript/src/components/utils.js @@ -74,9 +74,7 @@ export const parseSearchParams = url => { export const parseVersionOptions = (fragments = []) => fragments.map(f => ({ value: f.id, - label: `Version ${f.version} - updated on ${moment(f.submitted).format( - 'DD.MM.YYYY', - )}`, + label: `Version ${f.version}`, })) const alreadyAnswered = `You have already answered this invitation.` diff --git a/packages/component-user-manager/src/Users.js b/packages/component-user-manager/src/Users.js index 64f74bf44ddb9605a4df8aece054aa7227aa1937..c86df66c9d97bc496731d743faf7831c6f94b832 100644 --- a/packages/component-user-manager/src/Users.js +++ b/packages/component-user-manager/src/Users.js @@ -30,7 +30,7 @@ const Invite = app => { * "editorInChief": false, * "handlingEditor": false * } - * @apiErrorExample {json} Invite user errors + * @apiErrorExample {json} Reset password errors * HTTP/1.1 400 Bad Request * HTTP/1.1 404 Not Found */ @@ -43,6 +43,25 @@ const Invite = app => { '/api/users/confirm', require('./routes/users/confirm')(app.locals.models), ) + /** + * @api {post} /api/users/forgot-password Forgot password + * @apiGroup Users + * @apiParamExample {json} Body + * { + * "email": "email@example.com", + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * "message": "A password reset email has been sent to email@example.com" + * } + * @apiErrorExample {json} Forgot Password errors + * HTTP/1.1 400 Bad Request + */ + app.post( + '/api/users/forgot-password', + require('./routes/users/forgotPassword')(app.locals.models), + ) } module.exports = Invite diff --git a/packages/component-user-manager/src/routes/users/forgotPassword.js b/packages/component-user-manager/src/routes/users/forgotPassword.js new file mode 100644 index 0000000000000000000000000000000000000000..cbc5d3b686af0f74086420176bfe57bfcd00e635 --- /dev/null +++ b/packages/component-user-manager/src/routes/users/forgotPassword.js @@ -0,0 +1,50 @@ +const logger = require('@pubsweet/logger') +const { services } = require('pubsweet-component-helper-service') +const mailService = require('pubsweet-component-mail-service') + +module.exports = models => async (req, res) => { + const { email } = req.body + if (!services.checkForUndefinedParams(email)) + return res.status(400).json({ error: 'Email address is required.' }) + + try { + const user = await models.User.findByEmail(email) + if (user.passwordResetTimestamp) { + const resetDate = new Date(user.passwordResetTimestamp) + const hoursPassed = Math.floor( + (new Date().getTime() - resetDate) / 3600000, + ) + if (hoursPassed < 24) { + return res + .status(400) + .json({ error: 'A password reset has already been requested.' }) + } + } + + user.passwordResetToken = generatePasswordHash() + user.passwordResetTimestamp = Date.now() + await user.save() + + mailService.sendSimpleEmail({ + toEmail: user.email, + user, + emailType: 'forgot-password', + dashboardUrl: services.getBaseUrl(req), + }) + } catch (e) { + logger.error( + `A forgot password request has been made on an non-existent email: ${email}`, + ) + } + + res.status(200).json({ + message: `A password reset email has been sent to ${email}.`, + }) +} + +const generatePasswordHash = () => + Array.from({ length: 4 }, () => + Math.random() + .toString(36) + .slice(4), + ).join('') diff --git a/packages/component-user-manager/src/routes/users/resetPassword.js b/packages/component-user-manager/src/routes/users/resetPassword.js index 8a38aea627e1f0155ca26de947d80d2d6b7e375b..47e21eaf77b4ee9fa3f05a95afa3e6cbc7a30811 100644 --- a/packages/component-user-manager/src/routes/users/resetPassword.js +++ b/packages/component-user-manager/src/routes/users/resetPassword.js @@ -28,6 +28,7 @@ module.exports = models => async (req, res) => { req.body.isConfirmed = true delete user.passwordResetToken + delete user.passwordResetTimestamp delete req.body.token user = await user.updateProperties(req.body) diff --git a/packages/component-user-manager/src/tests/users/forgotPassword.test.js b/packages/component-user-manager/src/tests/users/forgotPassword.test.js new file mode 100644 index 0000000000000000000000000000000000000000..cb115917542489188e45a393ed1764b61ee8ccba --- /dev/null +++ b/packages/component-user-manager/src/tests/users/forgotPassword.test.js @@ -0,0 +1,78 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true + +const httpMocks = require('node-mocks-http') +const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService + +const { user, author } = fixtures.users +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) + +const reqBody = { + email: user.email, +} + +const notFoundError = new Error() +notFoundError.name = 'NotFoundError' +notFoundError.status = 404 +const forgotPasswordPath = '../../routes/users/forgotPassword' + +describe('Users forgot password route handler', () => { + let testFixtures = {} + let body = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + body = cloneDeep(reqBody) + models = Model.build(testFixtures) + }) + + it('should return an error when some parameters are missing', async () => { + delete body.email + const req = httpMocks.createRequest({ body }) + + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Email address is required.') + }) + it('should return an error when the user has already requested a password reset', async () => { + body.email = author.email + + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('A password reset has already been requested.') + }) + it('should return success when the body is correct', async () => { + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.message).toEqual( + `A password reset email has been sent to ${body.email}.`, + ) + }) + it('should return success if the email is non-existant', async () => { + body.email = 'email@example.com' + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.message).toEqual( + `A password reset email has been sent to ${body.email}.`, + ) + }) +}) diff --git a/packages/component-wizard/src/redux/conversion.js b/packages/component-wizard/src/redux/conversion.js index a7d861b8e023658d50cd84b2c90d65503598eac3..b88ae87c8175ffbbfbcba7d92ff1286932821795 100644 --- a/packages/component-wizard/src/redux/conversion.js +++ b/packages/component-wizard/src/redux/conversion.js @@ -84,10 +84,17 @@ export const createRevision = ( history, ) => dispatch => { // copy invitations only if minor revision - const { id, submitted, recommendations, ...prev } = previousVersion + const { + id, + submitted, + recommendations, + invitations, + ...prev + } = previousVersion return dispatch( actions.createFragment(collection, { ...prev, + invitations: invitations.filter(inv => inv.isAccepted), created: new Date(), version: previousVersion.version + 1, }), diff --git a/packages/components-faraday/src/components/Admin/EditUserForm.js b/packages/components-faraday/src/components/Admin/EditUserForm.js index 716b8f55a97e2c8135c173a1812b9453843315c3..95025eea1ab0eaf5174df5a7abd2d5c357890daa 100644 --- a/packages/components-faraday/src/components/Admin/EditUserForm.js +++ b/packages/components-faraday/src/components/Admin/EditUserForm.js @@ -108,6 +108,9 @@ const Row = styled.div` display: flex; flex-direction: row; margin: calc(${th('subGridUnit')}*3) 0; + div[role='alert'] { + margin-top: 0; + } ` const RowItem = styled.div` diff --git a/packages/components-faraday/src/components/AuthorList/AuthorAdder.js b/packages/components-faraday/src/components/AuthorList/AuthorAdder.js index 82f7305564a4aae1b3cd23b14e01421bfa7f7e4e..023eaa2c99b3243b6b7d9a010facd6ae17f87513 100644 --- a/packages/components-faraday/src/components/AuthorList/AuthorAdder.js +++ b/packages/components-faraday/src/components/AuthorList/AuthorAdder.js @@ -105,15 +105,7 @@ export default compose( onSubmit: ( values, dispatch, - { - reset, - match, - changeForm, - addAuthor, - setEditMode, - setFormAuthors, - authors = [], - }, + { reset, match, changeForm, addAuthor, setEditMode, authors = [] }, ) => { const collectionId = get(match, 'params.project') const fragmentId = get(match, 'params.version') diff --git a/packages/components-faraday/src/components/Dashboard/DashboardCard.js b/packages/components-faraday/src/components/Dashboard/DashboardCard.js index f126eca8ac1d52f65f8912729a3a4da95e0caa9b..59638dda62cd0f0059f530d3a11105723c954d82 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardCard.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardCard.js @@ -85,7 +85,9 @@ const DashboardCard = ({ <Icon>download</Icon> </ClickableIcon> </ZipFiles> - {(!project.status || project.status === 'draft') && ( + {(!project.status || + project.status === 'draft' || + !submittedDate) && ( <ActionButtons data-test="button-resume-submission" onClick={() => @@ -118,17 +120,19 @@ const DashboardCard = ({ {manuscriptMeta} </ManuscriptType> {project.status && project.status !== 'draft' ? ( - <Details - data-test="button-details" - onClick={() => - history.push( - `/projects/${project.id}/versions/${version.id}/details`, - ) - } - > - Details - <Icon primary>chevron-right</Icon> - </Details> + submittedDate && ( + <Details + data-test="button-details" + onClick={() => + history.push( + `/projects/${project.id}/versions/${version.id}/details`, + ) + } + > + Details + <Icon primary>chevron-right</Icon> + </Details> + ) ) : ( <DeleteManuscript deleteProject={() => deleteProject(project)} diff --git a/packages/components-faraday/src/components/Dashboard/DashboardFilters.js b/packages/components-faraday/src/components/Dashboard/DashboardFilters.js index 06063f305074ccf1ead8d370a29b3ea418887d09..2f98353f1746bbdee0236cc038e711471f523fbd 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardFilters.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardFilters.js @@ -4,12 +4,6 @@ import { Menu, th } from '@pubsweet/ui' import { compose, withHandlers } from 'recompose' const DashboardFilters = ({ - // view, - status, - listView, - createdAt, - changeSort, - changeFilter, getFilterOptions, changeFilterValue, getDefaultFilterValue, diff --git a/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js b/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js index 08bfbff9dc1dbd96821f8b40eb6e881604117aea..45a32fdb91a2d83c174d77729f3c76c77bef9118 100644 --- a/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js +++ b/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js @@ -99,7 +99,7 @@ const DecisionButton = styled(Button)` background-color: ${({ primary }) => primary ? th('colorPrimary') : th('backgroundColorReverse')}; color: ${({ primary }) => - primary ? th('colorTextReverse') : th('colorPrimary')}); + primary ? th('colorTextReverse') : th('colorPrimary')}; height: calc(${th('subGridUnit')} * 5); margin-left: ${th('gridUnit')}; padding: 0; diff --git a/packages/components-faraday/src/components/Filters/importanceSort.js b/packages/components-faraday/src/components/Filters/importanceSort.js index fd906879dcd0e8af3309ea28d24668984cad8d18..c2491a9219a11728956995948c5c5338025715e8 100644 --- a/packages/components-faraday/src/components/Filters/importanceSort.js +++ b/packages/components-faraday/src/components/Filters/importanceSort.js @@ -10,7 +10,7 @@ export const SORT_VALUES = { } const options = [ - { label: 'Imporant first', value: SORT_VALUES.MORE_IMPORTANT }, + { label: 'Important first', value: SORT_VALUES.MORE_IMPORTANT }, { label: 'Less important first', value: SORT_VALUES.LESS_IMPORTANT }, ] diff --git a/packages/components-faraday/src/components/Filters/withFilters.js b/packages/components-faraday/src/components/Filters/withFilters.js index 22c8ce546d6f8875b1e480316f5569a48cc470cc..d7a089f935293cb6345cfec1f1ccccca19e602fc 100644 --- a/packages/components-faraday/src/components/Filters/withFilters.js +++ b/packages/components-faraday/src/components/Filters/withFilters.js @@ -18,10 +18,7 @@ export default config => Component => { getFilterOptions: () => key => get(config, `${key}.options`) || [], getDefaultFilterValue: ({ filterValues }) => key => get(filterValues, key) || '', - changeFilterValue: ({ - setFilterValues, - filterValues, - }) => filterKey => value => { + changeFilterValue: ({ setFilterValues }) => filterKey => value => { // ugly but recompose doesn't pass the new state in the callback function let newState = {} setFilterValues( diff --git a/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js b/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js index b88236b8a85d70c5f8f63330afa84c4120b1e04b..e64aa55891df9a45d62c42f54c9a37a87c11c126 100644 --- a/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js +++ b/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js @@ -97,7 +97,7 @@ const ReviewersDetailsList = ({ {reviewers.map((r, index) => ( <TR index={index} - key={r.email} + key={`${`${r.email} ${index}`}`} renderAcceptedLabel={renderAcceptedLabel} reviewer={r} showConfirmResend={showConfirmResend} diff --git a/packages/components-faraday/src/components/SignUp/ReviewerDecline.js b/packages/components-faraday/src/components/SignUp/ReviewerDecline.js index 74f726dd83b84c55f25a7ff531081c3590aa1ca5..bb023319c639fe92ec3f17f2fc48ecc95269ad3f 100644 --- a/packages/components-faraday/src/components/SignUp/ReviewerDecline.js +++ b/packages/components-faraday/src/components/SignUp/ReviewerDecline.js @@ -39,10 +39,14 @@ export default compose( invitationToken, reviewerDecline, replace, + fragmentId, } = this.props - reviewerDecline(invitationId, collectionId, invitationToken).catch( - redirectToError(replace), - ) + reviewerDecline( + invitationId, + collectionId, + fragmentId, + invitationToken, + ).catch(redirectToError(replace)) }, }), )(ReviewerDecline) diff --git a/packages/components-faraday/src/components/index.js b/packages/components-faraday/src/components/index.js index 902783170936776d49e7d22e542c1ad670bf9699..525ddeb0919f6fc43f7c3a9869b490d110e55adb 100644 --- a/packages/components-faraday/src/components/index.js +++ b/packages/components-faraday/src/components/index.js @@ -6,6 +6,7 @@ export { default as Steps } from './Steps/Steps' export { default as Files } from './Files/Files' export { default as AppBar } from './AppBar/AppBar' export { default as AuthorList } from './AuthorList/AuthorList' +export { default as withVersion } from './Dashboard/withVersion.js' export { default as SortableList } from './SortableList/SortableList' export { Decision } diff --git a/packages/components-faraday/src/redux/recommendations.js b/packages/components-faraday/src/redux/recommendations.js index 4a3e1f70ff1efd7ef18b9fb4e1191a3e503a5361..4a62913c04cc48357bd9f82d65437732a36cf230 100644 --- a/packages/components-faraday/src/redux/recommendations.js +++ b/packages/components-faraday/src/redux/recommendations.js @@ -8,6 +8,10 @@ export const selectEditorialRecommendations = (state, fragmentId) => selectRecommendations(state, fragmentId).filter( r => r.recommendationType === 'editorRecommendation' && r.comments, ) +export const selectReviewRecommendations = (state, fragmentId) => + selectRecommendations(state, fragmentId).filter( + r => r.recommendationType === 'review', + ) // #endregion // #region Actions diff --git a/packages/components-faraday/src/redux/reviewers.js b/packages/components-faraday/src/redux/reviewers.js index acc31e86480a3d6b826d89d66a4f9d3302db7b43..73a7501d1ac367974c29bd220b0d7787228176d2 100644 --- a/packages/components-faraday/src/redux/reviewers.js +++ b/packages/components-faraday/src/redux/reviewers.js @@ -194,11 +194,12 @@ export const reviewerDecision = ( export const reviewerDecline = ( invitationId, collectionId, + fragmentId, invitationToken, ) => dispatch => { dispatch(reviewerDecisionRequest()) return update( - `/collections/${collectionId}/invitations/${invitationId}/decline`, + `/collections/${collectionId}/fragments/${fragmentId}/invitations/${invitationId}/decline`, { invitationToken, }, diff --git a/packages/xpub-faraday/config/default.js b/packages/xpub-faraday/config/default.js index b04435400779bae33656b0d0191beeeccb213739..c282bbfb1667d42dadfa2b1e00a47c0ede992835 100644 --- a/packages/xpub-faraday/config/default.js +++ b/packages/xpub-faraday/config/default.js @@ -66,6 +66,9 @@ module.exports = { 'invite-reset-password': { url: process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || '/invite', }, + 'forgot-password': { + url: process.env.PUBSWEET_FORGOT_PASSWORD_URL || '/forgot-password', + }, 'invite-reviewer': { url: process.env.PUBSWEET_INVITE_REVIEWER_URL || '/invite-reviewer', }, diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index ec4ff13a360763abf602ba41b92742870cbf3a4a..fa6222be84d3181f4942373e7b06a7b6cb6f9b3d 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -7,6 +7,7 @@ module.exports = { created: Joi.date(), title: Joi.string(), status: Joi.string(), + visibleStatus: Joi.string(), customId: Joi.string(), invitations: Joi.array(), handlingEditor: Joi.object(), diff --git a/packages/xpub-faraday/static/favicon.ico b/packages/xpub-faraday/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8a6bcd88d0d0461a41667716bc4f4390b4ec61a3 --- /dev/null +++ b/packages/xpub-faraday/static/favicon.ico @@ -0,0 +1 @@ +AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAgCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8CAAAAAAAAAAAAAAAAAAAAAP///wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJR0DzCAYwSphGID6ZFmANZ+bhR9IqCHhCadf9QloH7aKqKAnTCdfyoAAAAAAAAAAAAAAAAAAAAAAAAAAHleCFxkSwL/WkQB/14+AMhuZxnqP6Fu/ySpif8fpYj/JKaG/yy1jv8ion/6LJx8YgAAAAAAAAAAAAAAAHdeCVFiSgH/TzsC21U6BTAMv8wUH6GG2iKniP8xoXj/V4Q9/VqDTnwgoIV8KJt//iuviv8rmXxvAAAAAJV7ER1cQwH1TTsC41hCFhcAAAAAKJt8wymtiP8gmoPnZH01jphnAP+QZADY/wAAASuegTUpo3/kK66J/y2ffUN0WgObW0QB/08+DD0AAAAALZ1/VCqqhv8onH/+Lp9/SAAAAACEZgSxm3cA/4hqBYgAAAAANJZ4IiahePUon37Qb1oC8E03As8AAAAAAAAAACmcfKYrsYv/KZt6swAAAAAAAAAAiWkHaph1AP+GZwLFAAAAAAAAAAAbZ1G5JZl4/4RlAvlmTQLCAAAAAAAAAAAqnHywLLSO/yudfYAAAAAAAAAAAIVnBqiXdAD/hmkEtgAAAAAAAAAAFVE+1Rl/XviHaQSxkG4B/4FoDD0AAAAAK6CAaSyyjP8pnXzLAAAAAI5oB0KLZAH9k3EA/4ppCF4AAAAAG1dEUhllTP8jgmWmjGwMKI5qAvmHbQH6i2oGVAAAAAAcooi7H6+T/0mNVqaNYwDllHAA/4lnAsMAAAAAHV5NKxZSP/EVZk32NKWHIgAAAACEaQlNjm0C/5NxAf+LZACdcHsqeFqDPfmHcQv/kmkA/4hlAOJtVw4jE1JFTRZWQe8bbFP/KIhsUgAAAAAAAAAAAAAAAIVpC0GIawPkm3cA/5VuAP+TZgD/k2oA/317HP8shWP8EGlZ6BtsUv8ccFf8KopuWgAAAAAAAAAAAAAAAAAAAAAAAAAAhmsaE41tB4CKawLFimsCxZFkAnc0mXJiI6WKvCqffNMpnHuZN7SPKQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAPvfAADgBwAAwAMAAIABAAAIAAAAEIgAADGMAAAxjAAAEQgAAAgQAACAAQAAwAMAAOAHAAD//wAA//8AAA== \ No newline at end of file