From 4991f9cdc78bdb4dd3d6eed0684ff2ae44f42721 Mon Sep 17 00:00:00 2001 From: Davinder Singh Date: Thu, 8 Jun 2023 14:19:15 +0100 Subject: [PATCH] Merge pull request #13367 from overleaf/ab-group-invite-halfway-out-of-login [web] Move the group invite page half-way outside the login wall GitOrigin-RevId: 8d846df6e248a08433ab2ca991644c78cf9ff330 --- .../Subscription/SubscriptionRouter.js | 1 - .../Subscription/TeamInvitesController.js | 215 +++++++-------- .../Subscription/TeamInvitesHandler.js | 247 ++++++++++-------- .../subscriptions/team/invite_logged_out.pug | 24 ++ services/web/locales/en.json | 6 + .../Subscription/TeamInvitesHandlerTests.js | 20 +- 6 files changed, 286 insertions(+), 227 deletions(-) create mode 100644 services/web/app/views/subscriptions/team/invite_logged_out.pug diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.js b/services/web/app/src/Features/Subscription/SubscriptionRouter.js index a27e1507b6..114c389a8d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.js +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.js @@ -64,7 +64,6 @@ module.exports = { // Team invites webRouter.get( '/subscription/invites/:token/', - AuthenticationController.requireLogin(), TeamInvitesController.viewInvite ) webRouter.put( diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.js b/services/web/app/src/Features/Subscription/TeamInvitesController.js index 6206fe4242..606e7e094c 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.js @@ -4,122 +4,129 @@ const SessionManager = require('../Authentication/SessionManager') const SubscriptionLocator = require('./SubscriptionLocator') const ErrorController = require('../Errors/ErrorController') const EmailHelper = require('../Helpers/EmailHelper') +const UserGetter = require('../User/UserGetter') +const { expressify } = require('../../util/promises') -module.exports = { - createInvite(req, res, next) { - const teamManagerId = SessionManager.getLoggedInUserId(req.session) - const subscription = req.entity - const email = EmailHelper.parseEmail(req.body.email) - if (!email) { - return res.status(422).json({ - error: { - code: 'invalid_email', - message: req.i18n.translate('invalid_email'), - }, - }) +function createInvite(req, res, next) { + const teamManagerId = SessionManager.getLoggedInUserId(req.session) + const subscription = req.entity + const email = EmailHelper.parseEmail(req.body.email) + if (!email) { + return res.status(422).json({ + error: { + code: 'invalid_email', + message: req.i18n.translate('invalid_email'), + }, + }) + } + + TeamInvitesHandler.createInvite( + teamManagerId, + subscription, + email, + function (err, inviteUserData) { + if (err) { + if (err.alreadyInTeam) { + return res.status(400).json({ + error: { + code: 'user_already_added', + message: req.i18n.translate('user_already_added'), + }, + }) + } + if (err.limitReached) { + return res.status(400).json({ + error: { + code: 'group_full', + message: req.i18n.translate('group_full'), + }, + }) + } + return next(err) + } + res.json({ user: inviteUserData }) } + ) +} - TeamInvitesHandler.createInvite( - teamManagerId, - subscription, - email, - function (err, inviteUserData) { - if (err) { - if (err.alreadyInTeam) { - return res.status(400).json({ - error: { - code: 'user_already_added', - message: req.i18n.translate('user_already_added'), - }, - }) - } - if (err.limitReached) { - return res.status(400).json({ - error: { - code: 'group_full', - message: req.i18n.translate('group_full'), - }, - }) - } - return next(err) - } - res.json({ user: inviteUserData }) - } +async function viewInvite(req, res, next) { + const { token } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) + + const { invite } = await TeamInvitesHandler.promises.getInvite(token) + if (!invite) { + return ErrorController.notFound(req, res) + } + + if (userId) { + const personalSubscription = + await SubscriptionLocator.promises.getUsersSubscription(userId) + + const hasIndividualRecurlySubscription = + personalSubscription && + personalSubscription.groupPlan === false && + personalSubscription.recurlyStatus?.state !== 'canceled' && + personalSubscription.recurlySubscription_id && + personalSubscription.recurlySubscription_id !== '' + + res.render('subscriptions/team/invite', { + inviterName: invite.inviterName, + inviteToken: invite.token, + hasIndividualRecurlySubscription, + appName: settings.appName, + expired: req.query.expired, + }) + } else { + const userByEmail = await UserGetter.promises.getUserByMainEmail( + invite.email ) - }, - viewInvite(req, res, next) { - const { token } = req.params - const userId = SessionManager.getLoggedInUserId(req.session) + res.render('subscriptions/team/invite_logged_out', { + inviterName: invite.inviterName, + inviteToken: invite.token, + appName: settings.appName, + accountExists: userByEmail != null, + emailAddress: invite.email, + }) + } +} - TeamInvitesHandler.getInvite( - token, - function (err, invite, teamSubscription) { - if (err) { - return next(err) - } +function acceptInvite(req, res, next) { + const { token } = req.params + const userId = SessionManager.getLoggedInUserId(req.session) - if (!invite) { - return ErrorController.notFound(req, res, next) - } + TeamInvitesHandler.acceptInvite(token, userId, function (err, results) { + if (err) { + return next(err) + } + res.sendStatus(204) + }) +} - SubscriptionLocator.getUsersSubscription( - userId, - function (err, personalSubscription) { - if (err) { - return next(err) - } +function revokeInvite(req, res, next) { + const subscription = req.entity + const email = EmailHelper.parseEmail(req.params.email) + const teamManagerId = SessionManager.getLoggedInUserId(req.session) + if (!email) { + return res.sendStatus(400) + } - const hasIndividualRecurlySubscription = - personalSubscription && - personalSubscription.groupPlan === false && - personalSubscription.recurlyStatus?.state !== 'canceled' && - personalSubscription.recurlySubscription_id && - personalSubscription.recurlySubscription_id !== '' - - res.render('subscriptions/team/invite', { - inviterName: invite.inviterName, - inviteToken: invite.token, - hasIndividualRecurlySubscription, - appName: settings.appName, - expired: req.query.expired, - }) - } - ) - } - ) - }, - - acceptInvite(req, res, next) { - const { token } = req.params - const userId = SessionManager.getLoggedInUserId(req.session) - - TeamInvitesHandler.acceptInvite(token, userId, function (err, results) { + TeamInvitesHandler.revokeInvite( + teamManagerId, + subscription, + email, + function (err, results) { if (err) { return next(err) } res.sendStatus(204) - }) - }, - - revokeInvite(req, res, next) { - const subscription = req.entity - const email = EmailHelper.parseEmail(req.params.email) - const teamManagerId = SessionManager.getLoggedInUserId(req.session) - if (!email) { - return res.sendStatus(400) } - - TeamInvitesHandler.revokeInvite( - teamManagerId, - subscription, - email, - function (err, results) { - if (err) { - return next(err) - } - res.sendStatus(204) - } - ) - }, + ) +} + +module.exports = { + createInvite, + viewInvite: expressify(viewInvite), + acceptInvite, + revokeInvite, } diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js index c2bf783225..eaf4fca1ea 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.js @@ -1,4 +1,3 @@ -let TeamInvitesHandler const logger = require('@overleaf/logger') const crypto = require('crypto') const async = require('async') @@ -17,120 +16,125 @@ const EmailHandler = require('../Email/EmailHandler') const EmailHelper = require('../Helpers/EmailHelper') const Errors = require('../Errors/Errors') +const { promisifyMultiResult, promisify } = require('../../util/promises') -module.exports = TeamInvitesHandler = { - getInvite(token, callback) { - Subscription.findOne( - { 'teamInvites.token': token }, - function (err, subscription) { - if (err) { - return callback(err) - } - if (!subscription) { - return callback(new Errors.NotFoundError('team not found')) - } - - const invite = subscription.teamInvites.find(i => i.token === token) - callback(null, invite, subscription) +function getInvite(token, callback) { + Subscription.findOne( + { 'teamInvites.token': token }, + function (err, subscription) { + if (err) { + return callback(err) + } + if (!subscription) { + return callback(new Errors.NotFoundError('team not found')) } - ) - }, - createInvite(teamManagerId, subscription, email, callback) { - email = EmailHelper.parseEmail(email) - if (!email) { - return callback(new Error('invalid email')) + const invite = subscription.teamInvites.find(i => i.token === token) + callback(null, invite, subscription) } - UserGetter.getUser(teamManagerId, function (error, teamManager) { + ) +} + +function createInvite(teamManagerId, subscription, email, callback) { + email = EmailHelper.parseEmail(email) + if (!email) { + return callback(new Error('invalid email')) + } + UserGetter.getUser(teamManagerId, function (error, teamManager) { + if (error) { + return callback(error) + } + + _removeLegacyInvite(subscription.id, email, function (error) { if (error) { return callback(error) } - - removeLegacyInvite(subscription.id, email, function (error) { - if (error) { - return callback(error) - } - createInvite(subscription, email, teamManager, callback) - }) + _createInvite(subscription, email, teamManager, callback) }) - }, - - importInvite(subscription, inviterName, email, token, sentAt, callback) { - checkIfInviteIsPossible( - subscription, - email, - function (error, possible, reason) { - if (error) { - return callback(error) - } - if (!possible) { - return callback(reason) - } - - subscription.teamInvites.push({ - email, - inviterName, - token, - sentAt, - }) - - subscription.save(callback) - } - ) - }, - - acceptInvite(token, userId, callback) { - TeamInvitesHandler.getInvite(token, function (err, invite, subscription) { - if (err) { - return callback(err) - } - if (!invite) { - return callback(new Errors.NotFoundError('invite not found')) - } - - SubscriptionUpdater.addUserToGroup( - subscription._id, - userId, - function (err) { - if (err) { - return callback(err) - } - - removeInviteFromTeam(subscription.id, invite.email, callback) - } - ) - }) - }, - - revokeInvite(teamManagerId, subscription, email, callback) { - email = EmailHelper.parseEmail(email) - if (!email) { - return callback(new Error('invalid email')) - } - removeInviteFromTeam(subscription.id, email, callback) - }, - - // Legacy method to allow a user to receive a confirmation email if their - // email is in Subscription.invited_emails when they join. We'll remove this - // after a short while. - createTeamInvitesForLegacyInvitedEmail(email, callback) { - SubscriptionLocator.getGroupsWithEmailInvite(email, function (err, teams) { - if (err) { - return callback(err) - } - - async.map( - teams, - (team, cb) => - TeamInvitesHandler.createInvite(team.admin_id, team, email, cb), - callback - ) - }) - }, + }) } -function createInvite(subscription, email, inviter, callback) { - checkIfInviteIsPossible( +function importInvite( + subscription, + inviterName, + email, + token, + sentAt, + callback +) { + _checkIfInviteIsPossible( + subscription, + email, + function (error, possible, reason) { + if (error) { + return callback(error) + } + if (!possible) { + return callback(reason) + } + + subscription.teamInvites.push({ + email, + inviterName, + token, + sentAt, + }) + + subscription.save(callback) + } + ) +} + +function acceptInvite(token, userId, callback) { + getInvite(token, function (err, invite, subscription) { + if (err) { + return callback(err) + } + if (!invite) { + return callback(new Errors.NotFoundError('invite not found')) + } + + SubscriptionUpdater.addUserToGroup( + subscription._id, + userId, + function (err) { + if (err) { + return callback(err) + } + + _removeInviteFromTeam(subscription.id, invite.email, callback) + } + ) + }) +} + +function revokeInvite(teamManagerId, subscription, email, callback) { + email = EmailHelper.parseEmail(email) + if (!email) { + return callback(new Error('invalid email')) + } + _removeInviteFromTeam(subscription.id, email, callback) +} + +// Legacy method to allow a user to receive a confirmation email if their +// email is in Subscription.invited_emails when they join. We'll remove this +// after a short while. +function createTeamInvitesForLegacyInvitedEmail(email, callback) { + SubscriptionLocator.getGroupsWithEmailInvite(email, function (err, teams) { + if (err) { + return callback(err) + } + + async.map( + teams, + (team, cb) => createInvite(team.admin_id, team, email, cb), + callback + ) + }) +} + +function _createInvite(subscription, email, inviter, callback) { + _checkIfInviteIsPossible( subscription, email, function (error, possible, reason) { @@ -155,7 +159,7 @@ function createInvite(subscription, email, inviter, callback) { } // legacy: remove any invite that might have been created in the past - removeInviteFromTeam(subscription._id, email, error => { + _removeInviteFromTeam(subscription._id, email, error => { const inviteUserData = { email: inviter.email, first_name: inviter.first_name, @@ -168,7 +172,7 @@ function createInvite(subscription, email, inviter, callback) { ) } - const inviterName = getInviterName(inviter) + const inviterName = _getInviterName(inviter) let invite = subscription.teamInvites.find( invite => invite.email === email ) @@ -206,20 +210,20 @@ function createInvite(subscription, email, inviter, callback) { ) } -function removeInviteFromTeam(subscriptionId, email, callback) { +function _removeInviteFromTeam(subscriptionId, email, callback) { const searchConditions = { _id: new ObjectId(subscriptionId.toString()) } const removeInvite = { $pull: { teamInvites: { email } } } async.series( [ cb => Subscription.updateOne(searchConditions, removeInvite, cb), - cb => removeLegacyInvite(subscriptionId, email, cb), + cb => _removeLegacyInvite(subscriptionId, email, cb), ], callback ) } -const removeLegacyInvite = (subscriptionId, email, callback) => +const _removeLegacyInvite = (subscriptionId, email, callback) => Subscription.updateOne( { _id: new ObjectId(subscriptionId.toString()), @@ -232,7 +236,7 @@ const removeLegacyInvite = (subscriptionId, email, callback) => callback ) -function checkIfInviteIsPossible(subscription, email, callback) { +function _checkIfInviteIsPossible(subscription, email, callback) { if (!subscription.groupPlan) { logger.debug( { subscriptionId: subscription.id }, @@ -273,7 +277,7 @@ function checkIfInviteIsPossible(subscription, email, callback) { }) } -function getInviterName(inviter) { +function _getInviterName(inviter) { let inviterName if (inviter.first_name && inviter.last_name) { inviterName = `${inviter.first_name} ${inviter.last_name} (${inviter.email})` @@ -283,3 +287,22 @@ function getInviterName(inviter) { return inviterName } + +module.exports = { + getInvite, + createInvite, + importInvite, + acceptInvite, + revokeInvite, + createTeamInvitesForLegacyInvitedEmail, + promises: { + getInvite: promisifyMultiResult(getInvite, ['invite', 'subscription']), + createInvite: promisify(createInvite), + importInvite: promisify(importInvite), + acceptInvite: promisify(acceptInvite), + revokeInvite: promisify(revokeInvite), + createTeamInvitesForLegacyInvitedEmail: promisify( + createTeamInvitesForLegacyInvitedEmail + ), + }, +} diff --git a/services/web/app/views/subscriptions/team/invite_logged_out.pug b/services/web/app/views/subscriptions/team/invite_logged_out.pug new file mode 100644 index 0000000000..ade872b647 --- /dev/null +++ b/services/web/app/views/subscriptions/team/invite_logged_out.pug @@ -0,0 +1,24 @@ +extends ../../layout + +block content + main.content.content-alt.team-invite#main-content + .container + .row + .col-md-8.col-md-offset-2.text-center + .card + .page-header + h1.text-centered #{translate("invited_to_group", {inviterName: inviterName, appName: appName})} + + if (accountExists) + div + p #{translate("invited_to_group_login_benefits", {appName: appName})} + p #{translate("invited_to_group_login", {emailAddress: emailAddress})} + p + a.btn.btn.btn-primary(href=`/login?redir=/subscription/invites/${inviteToken}`) #{translate("login_to_accept_invitation")} + else + div + p #{translate("invited_to_group_register_benefits", {appName: appName})} + p #{translate("invited_to_group_register", {inviterName: inviterName})} + p + a.btn.btn.btn-primary(href=`/register?redir=/subscription/invites/${inviteToken}`) #{translate("register_to_accept_invitation")} + diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 1443bef1cf..e902ef6a72 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -798,6 +798,10 @@ "invite_not_valid": "This is not a valid project invite", "invite_not_valid_description": "The invite may have expired. Please contact the project owner", "invited_to_group": "__inviterName__ has invited you to join a group subscription on __appName__", + "invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.", + "invited_to_group_login_benefits": "As part of this group, you’ll have access to __appName__ premium features such as additional collaborators, greater maximum compile time, and real-time track changes.", + "invited_to_group_register": "To accept __inviterName__’s invitation you’ll need to create an account.", + "invited_to_group_register_benefits": "__appName__ is a collaborative online LaTeX editor, with thousands of ready-to-use templates and an array of LaTeX learning resources to help you get started.", "invited_to_join": "You have been invited to join", "ip_address": "IP Address", "is_email_affiliated": "Is your email affiliated with an institution? ", @@ -911,6 +915,7 @@ "login_here": "Login here", "login_or_password_wrong_try_again": "Your login or password is incorrect. Please try again", "login_register_or": "or", + "login_to_accept_invitation": "Login to accept invitation", "login_to_overleaf": "Log in to Overleaf", "login_with_email": "Log in with your email", "login_with_service": "Log in with __service__", @@ -1268,6 +1273,7 @@ "register": "Register", "register_error": "Registration error", "register_intercept_sso": "You can link your __authProviderName__ account from the Account Settings page after logging in.", + "register_to_accept_invitation": "Register to accept invitation", "register_to_edit_template": "Please register to edit the __templateName__ template", "register_using_email": "Register using your email", "register_using_service": "Register using __service__", diff --git a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js index bc43f23625..1d28f4c37c 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js +++ b/services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js @@ -340,18 +340,18 @@ describe('TeamInvitesHandler', function () { it('sends an invitation email to addresses in the legacy invited_emails field', function (done) { this.TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail( 'eddard@example.com', - (err, invite) => { + (err, invites) => { expect(err).not.to.exist + expect(invites.length).to.eq(1) - this.TeamInvitesHandler.createInvite - .calledWith( - this.subscription.admin_id, - this.subscription, - 'eddard@example.com' - ) - .should.eq(true) - - this.TeamInvitesHandler.createInvite.callCount.should.eq(1) + const [invite] = invites + expect(invite.token).to.eq(this.newToken) + expect(invite.email).to.eq('eddard@example.com') + expect(invite.inviterName).to.eq( + 'Daenerys Targaryen (daenerys@example.com)' + ) + expect(invite.invite).to.be.true + expect(this.subscription.teamInvites).to.deep.include(invite) done() }