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
This commit is contained in:
Davinder Singh 2023-06-08 14:19:15 +01:00 committed by Copybot
parent 480ec139ab
commit 4991f9cdc7
6 changed files with 286 additions and 227 deletions

View file

@ -64,7 +64,6 @@ module.exports = {
// Team invites
webRouter.get(
'/subscription/invites/:token/',
AuthenticationController.requireLogin(),
TeamInvitesController.viewInvite
)
webRouter.put(

View file

@ -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,
}

View file

@ -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
),
},
}

View file

@ -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")}

View file

@ -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, youll 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 youll 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__",

View file

@ -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()
}