diff --git a/packages/component-email/src/routes/emails/helpers.js b/packages/component-email/src/routes/emails/helpers.js index fe28ae508c0199466df06fa19e94dfa88b111aa7..f0473d53b17cd007085170f8d25542075d8d4e2b 100644 --- a/packages/component-email/src/routes/emails/helpers.js +++ b/packages/component-email/src/routes/emails/helpers.js @@ -31,7 +31,7 @@ module.exports = { email.content.subject = 'Confirm your email address' email.content.ctaLink = services.createUrl(baseUrl, confirmSignUp, { userId: user.id, - confirmationToken: user.confirmationToken, + confirmationToken: user.accessTokens.confirmation, }) const { html, text } = email.getBody({ diff --git a/packages/component-email/src/routes/emails/notifications.js b/packages/component-email/src/routes/emails/notifications.js index 28791d3bae1ac9a59af53b0c7a6620aba7184c0e..0d4f67258a614657a471ecc0a38b4ba72a994407 100644 --- a/packages/component-email/src/routes/emails/notifications.js +++ b/packages/component-email/src/routes/emails/notifications.js @@ -9,7 +9,7 @@ const { Email, services } = require('pubsweet-component-helper-service') const { sendNewUserEmail, sendSignupEmail } = require('./helpers') module.exports = { - async sendNotifications({ user, baseUrl, role, UserModel }) { + async sendNotifications({ user, baseUrl, role }) { const email = new Email({ type: 'user', fromEmail: `Hindawi <${staffEmail}>`, @@ -20,7 +20,7 @@ module.exports = { content: { ctaLink: services.createUrl(baseUrl, resetPath, { email: user.email, - token: user.passwordResetToken, + token: user.accessTokens.passwordReset, firstName: user.firstName, lastName: user.lastName, affiliation: user.affiliation, @@ -30,6 +30,7 @@ module.exports = { signatureName: 'Hindawi', unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { id: user.id, + token: user.accessTokens.unsubscribe, }), }, }) diff --git a/packages/component-email/src/routes/emails/post.js b/packages/component-email/src/routes/emails/post.js index bb7b30d36cd62189b68cca80c9cf5df08be1cda8..246322fa3a7da378a16d3ade2b684c405b6ec5b8 100644 --- a/packages/component-email/src/routes/emails/post.js +++ b/packages/component-email/src/routes/emails/post.js @@ -22,7 +22,7 @@ module.exports = models => async (req, res) => { const user = await UserModel.findByEmail(email) if (type === 'signup') { - if (!user.confirmationToken) { + if (!user.accessTokens.confirmation) { return res .status(400) .json({ error: 'User does not have a confirmation token.' }) diff --git a/packages/component-fixture-manager/src/fixtures/userData.js b/packages/component-fixture-manager/src/fixtures/userData.js index d16f25b857f0e7c9cdc2e8346f8da2635f6e3cda..c6291916908889cf22eaf26ed80c52aebab500bb 100644 --- a/packages/component-fixture-manager/src/fixtures/userData.js +++ b/packages/component-fixture-manager/src/fixtures/userData.js @@ -20,4 +20,5 @@ module.exports = { answerHE: generateUserData(), inactiveReviewer: generateUserData(), inactiveUser: generateUserData(), + editorInChief: generateUserData(), } diff --git a/packages/component-fixture-manager/src/fixtures/users.js b/packages/component-fixture-manager/src/fixtures/users.js index e118c2bceddd2340a4b63156e6246666cdb8d37d..a315e0e1b720d98a1de3fabcb478d2f428b34ad2 100644 --- a/packages/component-fixture-manager/src/fixtures/users.js +++ b/packages/component-fixture-manager/src/fixtures/users.js @@ -1,303 +1,66 @@ -const { heTeamID, revTeamID, authorTeamID } = require('./teamIDs') -const { - handlingEditor, - user, - admin, - author, - reviewer, - answerReviewer, - submittingAuthor, - recReviewer, - answerHE, - inactiveReviewer, - inactiveUser, -} = require('./userData') const Chance = require('chance') +const usersData = require('./userData') const chance = new Chance() -const users = { - admin: { - type: 'user', - username: 'admin', - email: admin.email, - firstName: admin.firstName, - lastName: admin.lastName, - password: 'test', - admin: true, - id: admin.id, - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - editorInChief: { - type: 'user', - username: chance.word(), - email: chance.email(), - password: 'password', - admin: false, - id: chance.guid(), - firstName: chance.first(), - lastName: chance.last(), - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.editorInChief), - isConfirmed: false, - editorInChief: true, - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - handlingEditor: { - type: 'user', - username: chance.word(), - email: handlingEditor.email, - password: 'password', - admin: false, - id: handlingEditor.id, - firstName: handlingEditor.firstName, - lastName: handlingEditor.lastName, - teams: [heTeamID], - save: jest.fn(() => users.handlingEditor), - editorInChief: false, - handlingEditor: true, - title: 'Mr', - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - answerHE: { - type: 'user', - username: chance.word(), - email: answerHE.email, - password: 'password', - admin: false, - id: answerHE.id, - firstName: answerHE.firstName, - lastName: answerHE.lastName, - teams: [heTeamID], - save: jest.fn(() => users.answerHE), - editorInChief: false, - handlingEditor: true, - title: 'Mr', - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - user: { +const { heTeamID, revTeamID, authorTeamID } = require('./teamIDs') + +const keys = Object.keys(usersData) +let users = {} +users = keys.reduce((obj, item) => { + const userData = usersData[item] + const isHE = item === 'answerHE' || item === 'handlingEditor' + let teams = [] + + if (isHE) { + teams = [heTeamID] + } + if (item === 'author') { + teams = [authorTeamID] + } + if ( + ['reviewer', 'answerReviewer', 'recReviewer', 'inactiveReviewer'].includes( + item, + ) + ) { + teams = [revTeamID] + } + + obj[item] = { + ...userData, + teams, type: 'user', - username: chance.word(), - email: user.email, + username: item, password: 'password', - admin: false, - id: user.id, - passwordResetToken: chance.hash(), - firstName: user.firstName, - lastName: user.lastName, + handlingEditor: isHE, + token: chance.hash(), + admin: item === 'admin', affiliation: chance.company(), - title: 'Mr', + isConfirmed: item === 'author', + isActive: !['inactiveUser', 'inactiveReviewer'].includes(item), + editorInChief: item === 'editorInChief', + updateProperties: jest.fn(() => users[item]), + passwordResetTimestamp: item === 'author' ? Date.now() : undefined, save: jest.fn(function save() { return this }), - isConfirmed: false, - updateProperties: jest.fn(() => users.user), - teams: [], - confirmationToken: chance.hash(), - validPassword: jest.fn(function validPassword(password) { - return this.password === password - }), - token: chance.hash(), - isActive: true, notifications: { email: { user: true, system: true, }, }, - }, - author: { - type: 'user', - username: chance.word(), - email: author.email, - password: 'password', - admin: false, - id: author.id, - firstName: author.firstName, - lastName: author.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.author), - isConfirmed: true, - passwordResetToken: chance.hash(), - passwordResetTimestamp: Date.now(), - teams: [authorTeamID], - confirmationToken: chance.hash(), - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, + accessTokens: { + confirmation: chance.hash(), + unsubscribe: chance.hash(), + invitation: item === 'reviewer' ? chance.hash() : undefined, + passwordReset: item === 'user' ? chance.hash() : undefined, }, - }, - reviewer: { - type: 'user', - username: chance.word(), - email: reviewer.email, - password: 'password', - admin: false, - id: reviewer.id, - firstName: reviewer.firstName, - lastName: reviewer.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.reviewer), - isConfirmed: true, - teams: [revTeamID], - invitationToken: 'inv-token-123', - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - answerReviewer: { - type: 'user', - username: chance.word(), - email: answerReviewer.email, - password: 'password', - admin: false, - id: answerReviewer.id, - firstName: answerReviewer.firstName, - lastName: answerReviewer.lastName, - affiliation: chance.company(), - title: 'Dr', - save: jest.fn(() => users.answerReviewer), - isConfirmed: true, - teams: [revTeamID], - invitationToken: 'inv-token-123', - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - submittingAuthor: { - type: 'user', - username: 'sauthor', - email: submittingAuthor.email, - password: 'password', - admin: false, - id: submittingAuthor.id, - passwordResetToken: chance.hash(), - firstName: submittingAuthor.firstName, - lastName: submittingAuthor.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.submittingAuthor), - isConfirmed: false, - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - recReviewer: { - type: 'user', - username: chance.word(), - email: recReviewer.email, - password: 'password', - admin: false, - id: recReviewer.id, - firstName: recReviewer.firstName, - lastName: recReviewer.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.recReviewer), - isConfirmed: true, - teams: [revTeamID], - isActive: true, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - inactiveReviewer: { - type: 'user', - username: chance.word(), - email: inactiveReviewer.email, - password: 'password', - admin: false, - id: inactiveReviewer.id, - firstName: inactiveReviewer.firstName, - lastName: inactiveReviewer.lastName, - affiliation: chance.company(), - title: 'Dr', - save: jest.fn(() => users.inactiveReviewer), - isConfirmed: true, - teams: [revTeamID], - isActive: false, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, - inactiveUser: { - type: 'user', - username: chance.word(), - email: inactiveUser.email, - password: 'password', - admin: false, - id: inactiveUser.id, - passwordResetToken: chance.hash(), - firstName: inactiveUser.firstName, - lastName: inactiveUser.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(function save() { - return this - }), - isConfirmed: false, - updateProperties: jest.fn(() => users.inactiveUser), - teams: [], - confirmationToken: chance.hash(), validPassword: jest.fn(function validPassword(password) { return this.password === password }), - token: chance.hash(), - isActive: false, - notifications: { - email: { - user: true, - system: true, - }, - }, - }, -} + } + + return obj +}, {}) module.exports = users diff --git a/packages/component-helper-service/src/services/User.js b/packages/component-helper-service/src/services/User.js index 09c722d31cabe70492fb2c0fcdae381ee4c0c1ef..e37a835dce2cc83b7541bae9f22a808bf86eb908 100644 --- a/packages/component-helper-service/src/services/User.js +++ b/packages/component-helper-service/src/services/User.js @@ -1,6 +1,6 @@ const uuid = require('uuid') const { get } = require('lodash') -const crypto = require('crypto') +const services = require('./services') class User { constructor({ UserModel = {} }) { @@ -16,23 +16,26 @@ class User { username, email, password, - passwordResetToken: crypto.randomBytes(32).toString('hex'), - isConfirmed: false, firstName, lastName, affiliation, title, - editorInChief: role === 'editorInChief', + isActive: true, + isConfirmed: false, admin: role === 'admin', + editorInChief: role === 'editorInChief', handlingEditor: role === 'handlingEditor', - invitationToken: role === 'reviewer' ? uuid.v4() : '', - isActive: true, notifications: { email: { system: true, user: true, }, }, + accessTokens: { + invitation: role === 'reviewer' ? services.generateHash() : undefined, + unsubscribe: services.generateHash(), + passwordReset: services.generateHash(), + }, } let newUser = new UserModel(userBody) diff --git a/packages/component-helper-service/src/services/email/Email.js b/packages/component-helper-service/src/services/email/Email.js index 0271625e2cb99f8db804d43000b6b54a89e78892..0a7c2028bc6dde1ba7259459136a48a541eae620 100644 --- a/packages/component-helper-service/src/services/email/Email.js +++ b/packages/component-helper-service/src/services/email/Email.js @@ -85,6 +85,9 @@ class Email { logger.info( `EMAIL: Sent email from ${from} to ${to} with subject '${subject}'`, ) + logger.debug( + `EMAIL: Sent email from ${from} to ${to} with subject '${subject}'`, + ) SendEmail.send(mailData) } } diff --git a/packages/component-helper-service/src/services/services.js b/packages/component-helper-service/src/services/services.js index dab0fae58764405a3b8b4c5add996ee43657fe2b..d9f11a59a70ab9afb4aff0b8d856a6b34ce1a4b0 100644 --- a/packages/component-helper-service/src/services/services.js +++ b/packages/component-helper-service/src/services/services.js @@ -13,10 +13,10 @@ const validateEmailAndToken = async ({ email, token, userModel }) => { try { const user = await userModel.findByEmail(email) if (user) { - if (token !== user.passwordResetToken) { + if (token !== user.accessTokens.passwordReset) { logger.error( `invite pw reset tokens do not match: REQ ${token} vs. DB ${ - user.passwordResetToken + user.accessTokens.passwordReset }`, ) return { @@ -94,6 +94,14 @@ const getExpectedDate = ({ timestamp = Date.now(), daysExpected = 0 }) => { return expectedDate } + +const generateHash = () => + Array.from({ length: 4 }, () => + Math.random() + .toString(36) + .slice(4), + ).join('') + module.exports = { checkForUndefinedParams, validateEmailAndToken, @@ -101,4 +109,5 @@ module.exports = { getBaseUrl, createUrl, getExpectedDate, + generateHash, } diff --git a/packages/component-invite/src/routes/collectionsInvitations/emails/helpers.js b/packages/component-invite/src/routes/collectionsInvitations/emails/helpers.js index e2672c268c52646f102c957f2089a053802db8c2..0bdde9b698a634be1a0071414209d53f8b1d65fc 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/emails/helpers.js +++ b/packages/component-invite/src/routes/collectionsInvitations/emails/helpers.js @@ -30,6 +30,7 @@ module.exports = { unsubscribeSlug, { id: handlingEditor.id, + token: handlingEditor.accessTokens.unsubscribe, }, ) @@ -63,13 +64,7 @@ module.exports = { email: eic.email, } - email.content.unsubscribeLink = services.createUrl( - baseUrl, - unsubscribeSlug, - { - id: eic.id, - }, - ) + email.content.unsubscribeLink = services.createUrl(baseUrl) const { html, text } = email.getBody({ body: getEmailCopy({ diff --git a/packages/component-invite/src/routes/fragmentsInvitations/decline.js b/packages/component-invite/src/routes/fragmentsInvitations/decline.js index 5ac57713ff800ef188b2a977fc89d4fc1f56d18b..6d661344665ccd70f199eb3b7b024dd46010c033 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/decline.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/decline.js @@ -14,62 +14,73 @@ module.exports = models => async (req, res) => { return res.status(400).json({ error: 'Token is required' }) const UserModel = models.User + + const users = await UserModel.all() + + const user = users.find( + user => user.accessTokens.invitation === invitationToken, + ) + if (!user) { + return res.status(404).json({ + error: `User not found.`, + }) + } + + let collection = {} + let fragment = {} try { - const user = await UserModel.findOneByField( - 'invitationToken', - invitationToken, - ) - const collection = await models.Collection.find(collectionId) + collection = await models.Collection.find(collectionId) if (!collection.fragments.includes(fragmentId)) return res.status(400).json({ error: `Fragment ${fragmentId} does not match collection ${collectionId}`, }) - const fragment = await models.Fragment.find(fragmentId) - fragment.invitations = fragment.invitations || [] - const invitation = await fragment.invitations.find( - invitation => invitation.id === invitationId, - ) - - const invitationHelper = new Invitation({ - userId: user.id, - role: 'reviewer', - invitation, + fragment = await models.Fragment.find(fragmentId) + } catch (err) { + const notFoundError = await services.handleNotFoundError(err, 'Item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, }) + } - const invitationValidation = invitationHelper.validateInvitation() - if (invitationValidation.error) - return res.status(invitationValidation.status).json({ - error: invitationValidation.error, - }) - - const authsome = authsomeHelper.getAuthsome(models) - const canPatch = await authsome.can(req.user, 'PATCH', invitation) - if (!canPatch) - return res.status(403).json({ - error: 'Unauthorized.', - }) - - invitation.respondedOn = Date.now() - invitation.hasAnswer = true - invitation.isAccepted = false - await fragment.save() + fragment.invitations = fragment.invitations || [] + const invitation = await fragment.invitations.find( + invitation => invitation.id === invitationId, + ) - const baseUrl = services.getBaseUrl(req) + const invitationHelper = new Invitation({ + userId: user.id, + role: 'reviewer', + invitation, + }) - notifications.sendNotifications({ - baseUrl, - fragment, - collection, - reviewer: user, - UserModel: models.User, - emailType: 'reviewer-declined', + const invitationValidation = invitationHelper.validateInvitation() + if (invitationValidation.error) + return res.status(invitationValidation.status).json({ + error: invitationValidation.error, }) - return res.status(200).json({}) - } catch (e) { - const notFoundError = await services.handleNotFoundError(e, 'item') - return res.status(notFoundError.status).json({ - error: notFoundError.message, + const authsome = authsomeHelper.getAuthsome(models) + const canPatch = await authsome.can(req.user, 'PATCH', invitation) + if (!canPatch) + return res.status(403).json({ + error: 'Unauthorized.', }) - } + + invitation.respondedOn = Date.now() + invitation.hasAnswer = true + invitation.isAccepted = false + await fragment.save() + + const baseUrl = services.getBaseUrl(req) + + notifications.sendNotifications({ + baseUrl, + fragment, + collection, + reviewer: user, + UserModel: models.User, + emailType: 'reviewer-declined', + }) + + return res.status(200).json({}) } diff --git a/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js b/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js index fdb2dd642133722baab938d04c2b47e6f48d43a1..aaddd1b1cc5af20e8f0751a34a5da7c0d6ebe422 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js @@ -50,7 +50,7 @@ module.exports = { agree: false, fragmentId: fragment.id, collectionId: collection.id, - invitationToken: invitedUser.invitationToken, + invitationToken: invitedUser.accessTokens.invitation, }) let agreeLink = services.createUrl(baseUrl, detailsPath, queryParams) @@ -58,11 +58,11 @@ module.exports = { if (!invitedUser.isConfirmed) { queryParams = { ...queryParams, + agree: true, + fragmentId: fragment.id, email: invitedUser.email, - token: invitedUser.passwordResetToken, collectionId: collection.id, - fragmentId: fragment.id, - agree: true, + token: invitedUser.accessTokens.passwordReset, } agreeLink = services.createUrl(baseUrl, inviteReviewerPath, queryParams) } @@ -88,6 +88,7 @@ module.exports = { signatureName: get(collection, 'handlingEditor.name', 'Hindawi'), unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { id: invitedUser.id, + token: invitedUser.accessTokens.unsubscribe, }), }, }) diff --git a/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js b/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js index c15717402311b42c516697898d97d2767ff44b5a..640974fbcceb3a6b7c53eb905fe7ab20d92b0afe 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js @@ -66,12 +66,13 @@ module.exports = { } if (['reviewer-accepted', 'reviewer-declined'].includes(emailType)) { + const heUser = await UserModel.find(handlingEditor.id) sendHandlingEditorEmail({ email, eicName, titleText, emailType, - handlingEditor, + handlingEditor: heUser, customId: collection.customId, targetUserName: `${reviewer.lastName}`, }) @@ -100,6 +101,7 @@ const sendHandlingEditorEmail = ({ : `${customId}: A reviewer has declined` email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { id: handlingEditor.id, + token: handlingEditor.accessTokens.unsubscribe, }) email.content.signatureName = eicName email.content.signatureJournal = '' @@ -141,6 +143,7 @@ const sendReviewerEmail = async ({ email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { id: reviewer.id, + token: reviewer.accessTokens.unsubscribe, }) const { html, text } = email.getBody({ diff --git a/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js b/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js index 0357e6c500ccc2a865352b93715dd8ea6fed7c83..746129e53cdb35d96903aa48bdb0ed2a165a2af2 100644 --- a/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js +++ b/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js @@ -12,7 +12,7 @@ jest.mock('@pubsweet/component-send-email', () => ({ })) const reqBody = { - invitationToken: 'inv-token-123', + invitationToken: fixtures.users.reviewer.accessTokens.invitation, } const patchPath = '../../routes/fragmentsInvitations/decline' describe('Decline fragments invitations route handler', () => { @@ -74,7 +74,7 @@ describe('Decline fragments invitations route handler', () => { expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') + expect(data.error).toEqual('Item not found') }) it('should return an error if the fragment does not exists', async () => { const { reviewer } = testFixtures.users @@ -136,7 +136,7 @@ describe('Decline fragments invitations route handler', () => { await require(patchPath)(models)(req, res) expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') + expect(data.error).toEqual('User not found.') }) it('should return an error when the invitation is already answered', async () => { const { reviewer } = testFixtures.users diff --git a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js index c7817167d36f538b3fed2c88ddb51eba0ece61c1..bd78d0169c9fadd3bf210d07f64f7f20c8af2b84 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js @@ -25,8 +25,9 @@ module.exports = { isMajorRecommendation, }) { const fragmentHelper = new Fragment({ fragment }) + const handlingEditor = get(collection, 'handlingEditor') const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor: collection.handlingEditor, + handlingEditor, }) const { submittingAuthor, @@ -53,14 +54,16 @@ module.exports = { const userHelper = new User({ UserModel }) const eicName = await userHelper.getEiCName() + if (isNewVersion) { + const heUser = await UserModel.find(handlingEditor.id) sendHandlingEditorEmail({ email, eicName, baseUrl, customId, title: parsedFragment.title, - handlingEditor: get(collection, 'handlingEditor', {}), + handlingEditor: heUser, }) } @@ -114,6 +117,7 @@ const sendHandlingEditorEmail = ({ } email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { id: handlingEditor.id, + token: handlingEditor.accessTokens.unsubscribe, }) email.content.signatureName = eicName email.content.subject = `${customId}: Revision submitted` @@ -154,6 +158,7 @@ const sendReviewersEmail = async ({ unsubscribeSlug, { id: reviewer.id, + token: reviewer.accessTokens.unsubscribe, }, ) @@ -219,7 +224,8 @@ const sendAuthorsEmail = async ({ return { ...author, isConfirmed: user.isConfirmed, - passwordResetToken: user.passwordResetToken, + unsubscribeToken: user.accessTokens.unsubscribe, + passwordResetToken: user.accessTokens.passwordReset, ...getEmailCopy({ emailType: author.isSubmitting ? 'submitting-author-manuscript-submitted' @@ -242,6 +248,7 @@ const sendAuthorsEmail = async ({ unsubscribeSlug, { id: author.id, + token: author.unsubscribeToken, }, ) diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/helpers.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/helpers.js index 813b50f59c642813a0abcb651853f5d0c0afd426..ffeb610de4b8dc4921ba7b0a89b4a186c89eddff 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/helpers.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/helpers.js @@ -19,6 +19,7 @@ module.exports = { unsubscribeSlug, { id: reviewer.id, + token: reviewer.accessTokens.unsubscribe, }, ) const { html, text } = email.getBody({ @@ -152,6 +153,7 @@ module.exports = { unsubscribeSlug, { id: handlingEditor.id, + token: handlingEditor.accessTokens.unsubscribe, }, ) @@ -266,6 +268,7 @@ module.exports = { unsubscribeSlug, { id: author.id, + token: author.accessTokens.unsubscribe, }, ) const { html, text } = email.getBody({ @@ -347,6 +350,7 @@ module.exports = { unsubscribeSlug, { id: author.id, + token: author.accessTokens.unsubscribe, }, ) const { html, text } = email.getBody({ diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js index 9d7420d37a338e0377f8f1af6091e5b25d0436ef..9f6c43f5ea5f30885ad0fa99dc01e372abba8d00 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js @@ -80,6 +80,8 @@ module.exports = { hasPeerReview(collection) && (recommendation !== 'publish' || hasEQA) ) { + const handlingEditor = get(collection, 'handlingEditor', {}) + const heUser = await UserModel.find(handlingEditor.id) email = helpers.updateEmailContentForHE({ email, baseUrl, @@ -88,7 +90,7 @@ module.exports = { recommendation, recommendationType, heLastName: collHelper.getHELastName(), - handlingEditor: get(collection, 'handlingEditor', {}), + handlingEditor: heUser, }) const emailType = helpers.getEmailTypeByRecommendationForHE({ recommendation, diff --git a/packages/component-user-manager/src/Users.js b/packages/component-user-manager/src/Users.js index 9cc9c3f43fdda0bf00af9765aa5d28ca43bcb008..2a61b4cff35bdfa5bfa5cd3e268fc12c8a58535b 100644 --- a/packages/component-user-manager/src/Users.js +++ b/packages/component-user-manager/src/Users.js @@ -133,6 +133,7 @@ const Users = app => { * @apiParamExample {json} Body * { * "id": "a6184463-b17a-42f8-b02b-ae1d755cdc6b", + * "token": "dssds-132131-42f8-b02b-ae1d755cdc6b" * "subscribe": true, * } * @apiSuccessExample {json} Success diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js b/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js index 71e544c00e30d2d1aa5cd894698484357c98a9d7..26bf63b940fc1a4511342026a5a948873ee0d6e3 100644 --- a/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js +++ b/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js @@ -45,6 +45,7 @@ module.exports = { ctaLink: services.createUrl(baseUrl, ''), unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { id: submittingAuthor.id, + token: submittingAuthor.accessTokens.unsubscribe, }), }, }) @@ -52,11 +53,11 @@ module.exports = { if (!submittingAuthor.isConfirmed) { email.content.ctaLink = services.createUrl(baseUrl, resetPath, { email: submittingAuthor.email, - token: submittingAuthor.passwordResetToken, + title: submittingAuthor.title, firstName: submittingAuthor.firstName, lastName: submittingAuthor.lastName, affiliation: submittingAuthor.affiliation, - title: submittingAuthor.title, + token: submittingAuthor.accessTokens.passwordReset, }) email.content.ctaText = 'CONFIRM ACCOUNT' } diff --git a/packages/component-user-manager/src/routes/users/changePassword.js b/packages/component-user-manager/src/routes/users/changePassword.js index 57c1b2b7b17826155673582772cc2578b5eb441a..30073accccd603baa60cdea466dc3a35ded680f1 100644 --- a/packages/component-user-manager/src/routes/users/changePassword.js +++ b/packages/component-user-manager/src/routes/users/changePassword.js @@ -14,6 +14,7 @@ module.exports = models => async (req, res) => { let user try { user = await models.User.find(req.user) + if (!await user.validPassword(password)) { return res.status(400).json({ error: 'Wrong username or password.' }) } diff --git a/packages/component-user-manager/src/routes/users/confirm.js b/packages/component-user-manager/src/routes/users/confirm.js index 9db497a2f5f147e1fc324fb862aeb02e9dd6f3cb..aa6875d0ce51d5e067016a8235456770d0d62109 100644 --- a/packages/component-user-manager/src/routes/users/confirm.js +++ b/packages/component-user-manager/src/routes/users/confirm.js @@ -21,7 +21,7 @@ module.exports = models => async (req, res) => { return res.status(403).json({ error: 'Unauthorized.' }) } - if (user.confirmationToken !== confirmationToken) { + if (user.accessTokens.confirmation !== confirmationToken) { return res.status(400).json({ error: 'Wrong confirmation token.' }) } @@ -29,7 +29,7 @@ module.exports = models => async (req, res) => { return res.status(400).json({ error: 'User is already confirmed.' }) user.isConfirmed = true - delete user.confirmationToken + delete user.accessTokens.confirmation await user.save() return res.status(200).json({ diff --git a/packages/component-user-manager/src/routes/users/emails/notifications.js b/packages/component-user-manager/src/routes/users/emails/notifications.js index 344b5c7acd0a51bef1b1cee98e4958df0c458252..461584e1983ee428a48d9336d4c774333a57307e 100644 --- a/packages/component-user-manager/src/routes/users/emails/notifications.js +++ b/packages/component-user-manager/src/routes/users/emails/notifications.js @@ -1,14 +1,12 @@ const config = require('config') +const { Email, services } = require('pubsweet-component-helper-service') + +const { getEmailCopy } = require('./emailCopy') const forgotPath = config.get('forgot-password.url') const { name: journalName, staffEmail } = config.get('journal') - const unsubscribeSlug = config.get('unsubscribe.url') -const { Email, services } = require('pubsweet-component-helper-service') - -const { getEmailCopy } = require('./emailCopy') - module.exports = { async sendNotifications({ user, baseUrl }) { const email = new Email({ @@ -17,7 +15,7 @@ module.exports = { content: { ctaLink: services.createUrl(baseUrl, forgotPath, { email: user.email, - token: user.passwordResetToken, + token: user.accessTokens.passwordReset, }), ctaText: 'RESET PASSWORD', }, @@ -35,6 +33,7 @@ const sendForgotPasswordEmail = ({ email, baseUrl, user }) => { email.content.subject = 'Forgot Password' email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { id: user.id, + token: user.accessTokens.unsubscribe, }) const { html, text } = email.getBody({ diff --git a/packages/component-user-manager/src/routes/users/forgotPassword.js b/packages/component-user-manager/src/routes/users/forgotPassword.js index 4d0eed77b30d5592f22219c69eb1c06246aeeaa6..55b750e69bfaa886aaae329843c1967e8be6c634 100644 --- a/packages/component-user-manager/src/routes/users/forgotPassword.js +++ b/packages/component-user-manager/src/routes/users/forgotPassword.js @@ -32,7 +32,7 @@ module.exports = models => async (req, res) => { } } - user.passwordResetToken = generatePasswordHash() + user.accessTokens.passwordReset = services.generateHash() user.passwordResetTimestamp = Date.now() await user.save() @@ -50,10 +50,3 @@ module.exports = models => async (req, res) => { 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/post.js b/packages/component-user-manager/src/routes/users/post.js index 32870956242c4642678383795848b6bb96946d80..8150e2ca77f46131c69325b591e5a58dcd93ad1c 100644 --- a/packages/component-user-manager/src/routes/users/post.js +++ b/packages/component-user-manager/src/routes/users/post.js @@ -29,16 +29,19 @@ module.exports = models => async (req, res) => { admin: false, isActive: true, isConfirmed: false, - handlingEditor: false, editorInChief: false, + handlingEditor: false, username: req.body.email, - confirmationToken: chance.hash(), notifications: { email: { system: true, user: true, }, }, + accessTokens: { + confirmation: chance.hash(), + unsubscribe: chance.hash(), + }, } } let user = new models.User(req.body) diff --git a/packages/component-user-manager/src/routes/users/resetPassword.js b/packages/component-user-manager/src/routes/users/resetPassword.js index a91886ac3c44cc22b2c5f8d29cb2de3769559dba..b46c9cafdccf18ddab7facd6e629cc4a0f92a065 100644 --- a/packages/component-user-manager/src/routes/users/resetPassword.js +++ b/packages/component-user-manager/src/routes/users/resetPassword.js @@ -25,7 +25,7 @@ module.exports = models => async (req, res) => { req.body.isConfirmed = true req.body.isActive = true - delete user.passwordResetToken + delete user.accessTokens.passwordReset delete user.passwordResetTimestamp delete req.body.token user = await user.updateProperties(req.body) diff --git a/packages/component-user-manager/src/routes/users/subscribe.js b/packages/component-user-manager/src/routes/users/subscribe.js index fa90c8481012b0c8e045842764c508d8b0332d48..63a141c9211099ad3692ca09c8b25df472656014 100644 --- a/packages/component-user-manager/src/routes/users/subscribe.js +++ b/packages/component-user-manager/src/routes/users/subscribe.js @@ -2,14 +2,17 @@ const { set } = require('lodash') const { services } = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { - const { subscribe, id } = req.body + const { subscribe, id, token } = req.body - if (!services.checkForUndefinedParams(subscribe, id)) + if (!services.checkForUndefinedParams(subscribe, id, token)) return res.status(400).json({ error: 'Missing required params.' }) let user try { user = await models.User.find(id) + if (user.accessTokens.unsubscribe !== token) { + return res.status(400).json({ error: `Invalid token: ${token}` }) + } set(user, 'notifications.email.user', subscribe) user = await user.save() diff --git a/packages/component-user-manager/src/tests/users/confirm.test.js b/packages/component-user-manager/src/tests/users/confirm.test.js index 0cf81bc161be3dd78733541aded262a6d5d34853..25e773657fb2ccf00ddf7eab2d72b7796e0b2ace 100644 --- a/packages/component-user-manager/src/tests/users/confirm.test.js +++ b/packages/component-user-manager/src/tests/users/confirm.test.js @@ -14,7 +14,7 @@ jest.mock('@pubsweet/component-send-email', () => ({ const reqBody = { userId: user.id, - confirmationToken: user.confirmationToken, + confirmationToken: user.accessTokens.confirmation, } const notFoundError = new Error() @@ -64,7 +64,9 @@ describe('Users confirm route handler', () => { }) it('should return an error when the user is already confirmed', async () => { body.userId = author.id - body.confirmationToken = author.confirmationToken + body.confirmationToken = author.accessTokens.confirmation + author.isConfirmed = true + const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() await require(forgotPasswordPath)(models)(req, res) @@ -81,7 +83,7 @@ describe('Users confirm route handler', () => { }) it('should return an error when the user is inactive', async () => { body.userId = inactiveUser.id - body.confirmationToken = inactiveUser.confirmationToken + body.confirmationToken = inactiveUser.accessTokens.confirmation const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() await require(forgotPasswordPath)(models)(req, res) diff --git a/packages/component-user-manager/src/tests/users/resetPassword.test.js b/packages/component-user-manager/src/tests/users/resetPassword.test.js index fbdfe985f0c65f818dda72552f8ea9f9bdd5daff..6b9dfc7e9fcae7d466155e1ad227cb8fce07da07 100644 --- a/packages/component-user-manager/src/tests/users/resetPassword.test.js +++ b/packages/component-user-manager/src/tests/users/resetPassword.test.js @@ -21,7 +21,7 @@ const reqBody = { title: user.title, affiliation: user.affiliation, password: 'password', - token: user.passwordResetToken, + token: user.accessTokens.passwordReset, isConfirmed: false, } diff --git a/packages/component-user-manager/src/tests/users/subscribe.test.js b/packages/component-user-manager/src/tests/users/subscribe.test.js new file mode 100644 index 0000000000000000000000000000000000000000..de994a0ab8f92b72c7958f5f13d3e502b6b6a345 --- /dev/null +++ b/packages/component-user-manager/src/tests/users/subscribe.test.js @@ -0,0 +1,77 @@ +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 Chance = require('chance') + +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService +const chance = new Chance() + +const { user } = fixtures.users +jest.mock('@pubsweet/component-send-email', () => ({ + send: jest.fn(), +})) + +const reqBody = { + id: user.id, + subscribe: chance.bool(), + token: user.accessTokens.unsubscribe, +} + +const notFoundError = new Error() +notFoundError.name = 'NotFoundError' +notFoundError.status = 404 +const forgotPasswordPath = '../../routes/users/subscribe' + +describe('Users subscribe/unsubscribe 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.id + 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('Missing required params.') + }) + it('should return an error when the unsubscribe token does not match', async () => { + body.token = 'invalid-token' + + 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(`Invalid token: ${body.token}`) + }) + it('should return an error when the user does not exist', async () => { + body.id = 'invalid-user-id' + + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('User not found') + }) + 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) + const data = JSON.parse(res._getData()) + + expect(data.user.notifications.email.user).toEqual(body.subscribe) + }) +}) diff --git a/packages/components-faraday/src/components/Admin/utils.js b/packages/components-faraday/src/components/Admin/utils.js index bbea0fd9095e78086b3c366b627ad9844e0a21bd..ce4787ba3d305626bfe9ad8ffaea0bfc19204f2c 100644 --- a/packages/components-faraday/src/components/Admin/utils.js +++ b/packages/components-faraday/src/components/Admin/utils.js @@ -20,7 +20,6 @@ export const setAdmin = values => { ...omit(newValues, ['role']), username: newValues.email, isConfirmed: false, - passwordResetToken: generatePasswordHash(), password: 'defaultpass', editorInChief: newValues.role === 'editorInChief', handlingEditor: newValues.role === 'handlingEditor', @@ -30,6 +29,10 @@ export const setAdmin = values => { user: true, }, }, + accessTokens: { + passwordReset: generatePasswordHash(), + unsubscribe: generatePasswordHash(), + }, } } @@ -52,6 +55,7 @@ export const parseUpdateUser = values => { 'isActive', 'username', 'country', + 'accessTokens', ] return pick(parsedValues, valuesToSave) diff --git a/packages/components-faraday/src/components/UserProfile/EmailNotifications.js b/packages/components-faraday/src/components/UserProfile/EmailNotifications.js index 0b58340f34d4be6f3c26b31ec6165f21aa8d8a5b..e69d09cb6e39c81824ee6566a4a84ac932294b0d 100644 --- a/packages/components-faraday/src/components/UserProfile/EmailNotifications.js +++ b/packages/components-faraday/src/components/UserProfile/EmailNotifications.js @@ -43,12 +43,13 @@ export default compose( withFetching, withHandlers({ setSubscription: ({ + token, userId, setFetching, changeEmailSubscription, }) => value => ({ hideModal, setModalError }) => { setFetching(true) - changeEmailSubscription(userId, value) + changeEmailSubscription(userId, value, token) .then(() => { setFetching(false) hideModal() @@ -60,6 +61,3 @@ export default compose( }, }), )(EmailNotifications) - -// #region styles -// #endregion diff --git a/packages/components-faraday/src/components/UserProfile/Unsubscribe.js b/packages/components-faraday/src/components/UserProfile/Unsubscribe.js new file mode 100644 index 0000000000000000000000000000000000000000..7057695ee48eebb5c723def37b2d6289d4f145b3 --- /dev/null +++ b/packages/components-faraday/src/components/UserProfile/Unsubscribe.js @@ -0,0 +1,54 @@ +import React, { Fragment } from 'react' +import { get } from 'lodash' +import { connect } from 'react-redux' +import { Button, H2 } from '@pubsweet/ui' +import { withJournal } from 'xpub-journal' +import { Row } from 'pubsweet-component-faraday-ui' +import { compose, lifecycle, withState } from 'recompose' + +import { parseSearchParams } from '../utils' +import { changeEmailSubscription } from '../../redux/users' + +const Unsubscribe = ({ message, history }) => ( + <Fragment> + <Row mt={3}> + <H2>{message}</H2> + </Row> + <Row mt={3}> + <Button onClick={() => history.replace('/')} primary> + Go to Dashboard + </Button> + </Row> + </Fragment> +) + +export default compose( + connect(null, { changeEmailSubscription }), + withState('message', 'setConfirmMessage', 'Loading...'), + withJournal, + lifecycle({ + componentDidMount() { + const { + journal, + location, + setConfirmMessage, + changeEmailSubscription, + } = this.props + const { id, token } = parseSearchParams(location.search) + const confirmMessage = `You have successfully unsubscribed. To re-subscribe go to your profile.` + const errorMessage = `Something went wrong. Please try again or contact ${get( + journal, + 'metadata.email', + 'technology@hindawi.com', + )}.` + + changeEmailSubscription(id, false, token) + .then(() => { + setConfirmMessage(confirmMessage) + }) + .catch(() => { + setConfirmMessage(errorMessage) + }) + }, + }), +)(Unsubscribe) diff --git a/packages/components-faraday/src/components/UserProfile/UserProfilePage.js b/packages/components-faraday/src/components/UserProfile/UserProfilePage.js index fab1c438c0326a3d5fd49122d084172fe199ca39..f77295673fdd027c32abda4c6328a58f8c0c1c9d 100644 --- a/packages/components-faraday/src/components/UserProfile/UserProfilePage.js +++ b/packages/components-faraday/src/components/UserProfile/UserProfilePage.js @@ -43,6 +43,7 @@ const UserProfilePage = ({ <EmailNotifications changeEmailSubscription={changeEmailSubscription} isSubscribed={get(user, 'notifications.email.user', true)} + token={get(user, 'accessTokens.unsubscribe')} userId={get(user, 'id')} /> <LinkOrcID diff --git a/packages/components-faraday/src/components/UserProfile/index.js b/packages/components-faraday/src/components/UserProfile/index.js index c1e8ffe88850d3ae0c91ea3b866a40bd27d9aaa6..a4f6733b4b9f42c32d44319000a0d9e9c5191bb1 100644 --- a/packages/components-faraday/src/components/UserProfile/index.js +++ b/packages/components-faraday/src/components/UserProfile/index.js @@ -1,2 +1,3 @@ export { default as LinkOrcID } from './LinkOrcID' export { default as EmailNotifications } from './EmailNotifications' +export { default as Unsubscribe } from './Unsubscribe' diff --git a/packages/components-faraday/src/redux/users.js b/packages/components-faraday/src/redux/users.js index 460f254083804ddddadef2baf826d461cd88f0d5..c16e8088535f2af5450d21496ee1c8e89ea89863 100644 --- a/packages/components-faraday/src/redux/users.js +++ b/packages/components-faraday/src/redux/users.js @@ -22,8 +22,13 @@ export const confirmUser = (userId, confirmationToken) => dispatch => return dispatch(loginSuccess(user)) }) -export const changeEmailSubscription = (id, subscribe = true) => dispatch => +export const changeEmailSubscription = ( + id, + subscribe = true, + token, +) => dispatch => update(`/users/subscribe`, { id, + token, subscribe, }).then(() => dispatch(actions.getCurrentUser())) diff --git a/packages/xpub-faraday/config/authsome-helpers.js b/packages/xpub-faraday/config/authsome-helpers.js index 4d4b8bc3f4ce0500b38a37520a94d30e3043b55c..130516c904c3673eca7b734b7ddff9759e885a56 100644 --- a/packages/xpub-faraday/config/authsome-helpers.js +++ b/packages/xpub-faraday/config/authsome-helpers.js @@ -185,12 +185,7 @@ const stripeFragmentByRole = ({ } } -const sensitiveUserProperties = [ - 'passwordResetToken', - 'passwordHash', - 'invitationToken', - 'confirmationToken', -] +const sensitiveUserProperties = ['passwordHash', 'accessTokens'] const getUsersList = async ({ UserModel, user }) => { const users = await UserModel.all() diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index 8b2dc4aae313169d35555b934fba2ae4fc2d4428..978904daf61d9ad44ee03ad187d33d30dc64ac83 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -142,8 +142,6 @@ module.exports = { teams: Joi.array(), editorInChief: Joi.boolean(), handlingEditor: Joi.boolean(), - invitationToken: Joi.string().allow(''), - confirmationToken: Joi.string().allow(''), agreeTC: Joi.boolean(), isActive: Joi.boolean(), notifications: Joi.object({ @@ -152,6 +150,12 @@ module.exports = { user: Joi.boolean().default(true), }), }), + accessTokens: Joi.object({ + confirmation: Joi.string().allow(''), + passwordReset: Joi.string().allow(''), + unsubscribe: Joi.string().allow(''), + invitation: Joi.string().allow(''), + }), }, team: { group: Joi.string(),