Merge pull request #619 from sharelatex/afc-email-tokens

Use emails with tokens for team invites
This commit is contained in:
James Allen 2018-06-12 08:30:23 +01:00 committed by GitHub
commit f7532e5854
31 changed files with 806 additions and 483 deletions

View file

@ -118,14 +118,16 @@ Thank you
description: "Join #{ opts.project.name } at ShareLaTeX" description: "Join #{ opts.project.name } at ShareLaTeX"
}) })
templates.completeJoinGroupAccount =
subject: _.template "Verify Email to join <%= group_name %> group" templates.verifyEmailToJoinTeam =
subject: _.template "<%= inviterName %> has invited you to join a team on #{settings.appName}"
layout: BaseWithHeaderEmailLayout layout: BaseWithHeaderEmailLayout
type:"notification" type:"notification"
plainTextTemplate: _.template """ plainTextTemplate: _.template """
Hi, please verify your email to join the <%= group_name %> and get your free premium account
Click this link to verify now: <%= completeJoinUrl %> Please click the button below to join the team and enjoy the benefits of an upgraded <%= appName %> account.
<%= acceptInviteUrl %>
Thank You Thank You
@ -133,16 +135,15 @@ Thank You
""" """
compiledTemplate: (opts) -> compiledTemplate: (opts) ->
SingleCTAEmailBody({ SingleCTAEmailBody({
title: "Verify Email to join #{ opts.group_name } group" title: "#{opts.inviterName} has invited you to join a team on #{settings.appName}"
greeting: "Hi," greeting: "Hi,"
message: "please verify your email to join the #{ opts.group_name } group and get your free premium account." message: "Please click the button below to join the team and enjoy the benefits of an upgraded #{ opts.appName } account."
secondaryMessage: null secondaryMessage: null
ctaText: "Verify now" ctaText: "Verify now"
ctaURL: opts.completeJoinUrl ctaURL: opts.acceptInviteUrl
gmailGoToAction: null gmailGoToAction: null
}) })
templates.testEmail = templates.testEmail =
subject: _.template "A Test Email from ShareLaTeX" subject: _.template "A Test Email from ShareLaTeX"
layout: BaseWithHeaderEmailLayout layout: BaseWithHeaderEmailLayout

View file

@ -56,7 +56,7 @@ V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype
ProjectHistoryDisabledError = (message) -> ProjectHistoryDisabledError = (message) ->
error = new Error(message) error = new Error(message)
error.name = "ProjectHistoryDisabledError " error.name = "ProjectHistoryDisabledError"
error.__proto__ = ProjectHistoryDisabledError.prototype error.__proto__ = ProjectHistoryDisabledError.prototype
return error return error
ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype ProjectHistoryDisabledError.prototype.__proto___ = Error.prototype

View file

@ -0,0 +1,47 @@
SubscriptionGroupHandler = require("./SubscriptionGroupHandler")
logger = require("logger-sharelatex")
SubscriptionLocator = require("./SubscriptionLocator")
ErrorsController = require("../Errors/ErrorController")
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
AuthenticationController = require('../Authentication/AuthenticationController')
TeamInvitesHandler = require('./TeamInvitesHandler')
async = require("async")
module.exports =
join: (req, res)->
user = AuthenticationController.getSessionUser(req)
licence = SubscriptionDomainHandler.getLicenceUserCanJoin(user)
if !licence?
return ErrorsController.notFound(req, res)
jobs =
partOfGroup: (cb)->
SubscriptionGroupHandler.isUserPartOfGroup user.id, licence.group_subscription_id, cb
subscription: (cb)->
SubscriptionLocator.getUsersSubscription user.id, cb
async.series jobs, (err, results)->
{ partOfGroup, subscription } = results
if partOfGroup
return res.redirect("/user/subscription/custom_account")
else
res.render "subscriptions/domain/join",
title: "Group Invitation"
group_subscription_id: licence.group_subscription_id
licenceName: licence.name
has_personal_subscription: subscription?
createInvite: (req, res, next)->
user = AuthenticationController.getSessionUser(req)
licence = SubscriptionDomainHandler.getLicenceUserCanJoin(user)
if !licence?
return ErrorsController.notFound(req, res)
TeamInvitesHandler.createDomainInvite user, licence, (err) ->
if err?
next(err)
else
res.sendStatus 200

View file

@ -6,7 +6,7 @@ Settings = require("settings-sharelatex")
CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler")
CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler") CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler")
module.exports = module.exports = LimitationsManager =
allowedNumberOfCollaboratorsInProject: (project_id, callback) -> allowedNumberOfCollaboratorsInProject: (project_id, callback) ->
ProjectGetter.getProject project_id, owner_ref: true, (error, project) => ProjectGetter.getProject project_id, owner_ref: true, (error, project) =>
return callback(error) if error? return callback(error) if error?
@ -20,7 +20,6 @@ module.exports =
else else
callback null, Settings.defaultPlanCode.collaborators callback null, Settings.defaultPlanCode.collaborators
canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) -> canAddXCollaborators: (project_id, x_collaborators, callback = (error, allowed)->) ->
@allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) => @allowedNumberOfCollaboratorsInProject project_id, (error, allowed_number) =>
return callback(error) if error? return callback(error) if error?
@ -56,6 +55,13 @@ module.exports =
return callback(err) if err? return callback(err) if err?
callback err, subscriptions.length > 0, subscriptions callback err, subscriptions.length > 0, subscriptions
teamHasReachedMemberLimit: (subscription) ->
currentTotal = (subscription.member_ids or []).length +
(subscription.teamInvites or []).length +
(subscription.invited_emails or []).length
return currentTotal >= subscription.membersLimit
hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)-> hasGroupMembersLimitReached: (user_id, callback = (err, limitReached, subscription)->)->
SubscriptionLocator.getUsersSubscription user_id, (err, subscription)-> SubscriptionLocator.getUsersSubscription user_id, (err, subscription)->
if err? if err?
@ -64,9 +70,6 @@ module.exports =
if !subscription? if !subscription?
logger.err user_id:user_id, "no subscription found for user" logger.err user_id:user_id, "no subscription found for user"
return callback("no subscription found") return callback("no subscription found")
currentTotal = (subscription.member_ids or []).length + (subscription.invited_emails or []).length
limitReached = currentTotal >= subscription.membersLimit
logger.log user_id:user_id, limitReached:limitReached, currentTotal: currentTotal, membersLimit: subscription.membersLimit, "checking if subscription members limit has been reached"
callback(err, limitReached, subscription)
getOwnerIdOfProject = (project_id, callback)-> limitReached = LimitationsManager.teamHasReachedMemberLimit(subscription)
callback(err, limitReached, subscription)

View file

@ -15,7 +15,7 @@ module.exports = SubscriptionDomainHandler =
getDomainLicencePage: (user)-> getDomainLicencePage: (user)->
licence = SubscriptionDomainHandler._findDomainLicence(user.email) licence = SubscriptionDomainHandler._findDomainLicence(user.email)
if licence?.verifyEmail if licence?.verifyEmail
return "/user/subscription/#{licence.subscription_id}/group/invited" return "/user/subscription/domain/join"
else else
return undefined return undefined

View file

@ -33,14 +33,6 @@ module.exports =
return res.sendStatus 500 return res.sendStatus 500
res.send() res.send()
removeEmailInviteFromGroup: (req, res)->
adminUserId = AuthenticationController.getLoggedInUserId(req)
email = req.params.email
logger.log {adminUserId, email}, "removing email invite from group subscription"
SubscriptionGroupHandler.removeEmailInviteFromGroup adminUserId, email, (err)->
return next(error) if error?
res.send()
removeSelfFromGroup: (req, res)-> removeSelfFromGroup: (req, res)->
adminUserId = req.query.admin_user_id adminUserId = req.query.admin_user_id
userToRemove_id = AuthenticationController.getLoggedInUserId(req) userToRemove_id = AuthenticationController.getLoggedInUserId(req)
@ -62,68 +54,6 @@ module.exports =
users: users users: users
subscription: subscription subscription: subscription
renderGroupInvitePage: (req, res)->
group_subscription_id = req.params.subscription_id
user_id = AuthenticationController.getLoggedInUserId(req)
licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(group_subscription_id)
if !licence?
return ErrorsController.notFound(req, res)
jobs =
partOfGroup: (cb)->
SubscriptionGroupHandler.isUserPartOfGroup user_id, licence.group_subscription_id, cb
subscription: (cb)->
SubscriptionLocator.getUsersSubscription user_id, cb
async.series jobs, (err, results)->
{partOfGroup, subscription} = results
if partOfGroup
return res.redirect("/user/subscription/custom_account")
else
res.render "subscriptions/group/invite",
title: "Group Invitation"
group_subscription_id:group_subscription_id
licenceName:licence.name
has_personal_subscription: subscription?
beginJoinGroup: (req, res)->
subscription_id = req.params.subscription_id
currentUser = AuthenticationController.getSessionUser(req)
if !currentUser?
logger.err {subscription_id}, "error getting current user"
return res.sendStatus 500
licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
if !licence?
return ErrorsController.notFound(req, res)
SubscriptionGroupHandler.sendVerificationEmail subscription_id, licence.name, currentUser.email, (err)->
if err?
res.sendStatus 500
else
res.sendStatus 200
completeJoin: (req, res)->
currentUser = AuthenticationController.getSessionUser(req)
subscription_id = req.params.subscription_id
if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
return ErrorsController.notFound(req, res)
email = currentUser?.email
logger.log subscription_id:subscription_id, user_id:currentUser?._id, email:email, "starting the completion of joining group"
SubscriptionGroupHandler.processGroupVerification email, subscription_id, req.query?.token, (err)->
if err? and err == "token_not_found"
return res.redirect "/user/subscription/#{subscription_id}/group/invited?expired=true"
else if err?
return res.sendStatus 500
else
logger.log subscription_id:subscription_id, email:email, "user successful completed join of group subscription"
return res.redirect "/user/subscription/#{subscription_id}/group/successful-join"
renderSuccessfulJoinPage: (req, res)->
subscription_id = req.params.subscription_id
licence = SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)
if !SubscriptionDomainHandler.findDomainLicenceBySubscriptionId(subscription_id)?
return ErrorsController.notFound(req, res)
res.render "subscriptions/group/successful_join",
title: "Sucessfully joined group"
licenceName:licence.name
exportGroupCsv: (req, res)-> exportGroupCsv: (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req) user_id = AuthenticationController.getLoggedInUserId(req)
logger.log user_id: user_id, "exporting group csv" logger.log user_id: user_id, "exporting group csv"

View file

@ -7,6 +7,7 @@ Subscription = require("../../models/Subscription").Subscription
LimitationsManager = require("./LimitationsManager") LimitationsManager = require("./LimitationsManager")
logger = require("logger-sharelatex") logger = require("logger-sharelatex")
OneTimeTokenHandler = require("../Security/OneTimeTokenHandler") OneTimeTokenHandler = require("../Security/OneTimeTokenHandler")
TeamInvitesHandler = require("./TeamInvitesHandler")
EmailHandler = require("../Email/EmailHandler") EmailHandler = require("../Email/EmailHandler")
settings = require("settings-sharelatex") settings = require("settings-sharelatex")
NotificationsBuilder = require("../Notifications/NotificationsBuilder") NotificationsBuilder = require("../Notifications/NotificationsBuilder")
@ -33,7 +34,7 @@ module.exports = SubscriptionGroupHandler =
userViewModel = buildUserViewModel(user) userViewModel = buildUserViewModel(user)
callback(err, userViewModel) callback(err, userViewModel)
else else
SubscriptionUpdater.addEmailInviteToGroup adminUserId, newEmail, (err) -> TeamInvitesHandler.createInvite adminUserId, newEmail, (err) ->
return callback(err) if err? return callback(err) if err?
userViewModel = buildEmailInviteViewModel(newEmail) userViewModel = buildEmailInviteViewModel(newEmail)
callback(err, userViewModel) callback(err, userViewModel)
@ -41,10 +42,6 @@ module.exports = SubscriptionGroupHandler =
removeUserFromGroup: (adminUser_id, userToRemove_id, callback)-> removeUserFromGroup: (adminUser_id, userToRemove_id, callback)->
SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback
removeEmailInviteFromGroup: (adminUser_id, email, callback) ->
SubscriptionUpdater.removeEmailInviteFromGroup adminUser_id, email, callback
replaceUserReferencesInGroups: (oldId, newId, callback) -> replaceUserReferencesInGroups: (oldId, newId, callback) ->
Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) -> Subscription.update {admin_id: oldId}, {admin_id: newId}, (error) ->
callback(error) if error? callback(error) if error?
@ -62,8 +59,13 @@ module.exports = SubscriptionGroupHandler =
getPopulatedListOfMembers: (adminUser_id, callback)-> getPopulatedListOfMembers: (adminUser_id, callback)->
SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)-> SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)->
users = [] users = []
for email in subscription.invited_emails or [] for email in subscription.invited_emails or []
users.push buildEmailInviteViewModel(email) users.push buildEmailInviteViewModel(email)
for teamInvite in subscription.teamInvites or []
users.push buildEmailInviteViewModel(teamInvite.email)
jobs = _.map subscription.member_ids, (user_id)-> jobs = _.map subscription.member_ids, (user_id)->
return (cb)-> return (cb)->
UserGetter.getUser user_id, (err, user)-> UserGetter.getUser user_id, (err, user)->
@ -85,58 +87,17 @@ module.exports = SubscriptionGroupHandler =
logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group" logger.log user_id:user_id, subscription_id:subscription_id, partOfGroup:partOfGroup, "checking if user is part of a group"
callback(err, partOfGroup) callback(err, partOfGroup)
sendVerificationEmail: (subscription_id, licenceName, email, callback)->
ONE_DAY_IN_S = 1000 * 60 * 60 * 24
OneTimeTokenHandler.getNewToken subscription_id, {expiresIn:ONE_DAY_IN_S}, (err, token)->
opts =
to : email
group_name: licenceName
completeJoinUrl: "#{settings.siteUrl}/user/subscription/#{subscription_id}/group/complete-join?token=#{token}"
EmailHandler.sendEmail "completeJoinGroupAccount", opts, callback
processGroupVerification: (userEmail, subscription_id, token, callback)->
logger.log userEmail:userEmail, subscription_id:subscription_id, "processing group verification for user"
OneTimeTokenHandler.getValueFromTokenAndExpire token, (err, token_subscription_id)->
if err? or subscription_id != token_subscription_id
logger.err userEmail:userEmail, token:token, "token value not found for processing group verification"
return callback("token_not_found")
SubscriptionLocator.getSubscription subscription_id, (err, subscription)->
if err?
logger.err err:err, subscription:subscription, userEmail:userEmail, subscription_id:subscription_id, "error getting subscription"
return callback(err)
if !subscription?
logger.warn subscription_id:subscription_id, userEmail:userEmail, "no subscription found"
return callback()
SubscriptionGroupHandler.addUserToGroup subscription?.admin_id, userEmail, callback
convertEmailInvitesToMemberships: (email, user_id, callback = (err) ->) ->
SubscriptionLocator.getGroupsWithEmailInvite email, (err, groups = []) ->
return callback(err) if err?
logger.log {email, user_id, groups}, "found groups to convert from email invite to member"
jobs = []
for group in groups
do (group) ->
jobs.push (cb) ->
SubscriptionUpdater.removeEmailInviteFromGroup group.admin_id, email, (err) ->
return cb(err) if err?
SubscriptionUpdater.addUserToGroup group.admin_id, user_id, (err) ->
return cb(err) if err?
logger.log {group_id: group._id, user_id, email}, "converted email invite to group membership"
return cb()
async.series jobs, callback
buildUserViewModel = (user)-> buildUserViewModel = (user)->
u = u =
email: user.email email: user.email
first_name: user.first_name first_name: user.first_name
last_name: user.last_name last_name: user.last_name
holdingAccount: user.holdingAccount invite: user.holdingAccount
_id: user._id _id: user._id
return u return u
buildEmailInviteViewModel = (email) -> buildEmailInviteViewModel = (email) ->
return { return {
email: email email: email
holdingAccount: true invite: true
} }

View file

@ -1,6 +1,8 @@
AuthenticationController = require('../Authentication/AuthenticationController') AuthenticationController = require('../Authentication/AuthenticationController')
SubscriptionController = require('./SubscriptionController') SubscriptionController = require('./SubscriptionController')
SubscriptionGroupController = require './SubscriptionGroupController' SubscriptionGroupController = require './SubscriptionGroupController'
DomainLicenceController = require './DomainLicenceController'
TeamInvitesController = require './TeamInvitesController'
Settings = require "settings-sharelatex" Settings = require "settings-sharelatex"
module.exports = module.exports =
@ -13,7 +15,6 @@ module.exports =
webRouter.get '/user/subscription/custom_account', AuthenticationController.requireLogin(), SubscriptionController.userCustomSubscriptionPage webRouter.get '/user/subscription/custom_account', AuthenticationController.requireLogin(), SubscriptionController.userCustomSubscriptionPage
webRouter.get '/user/subscription/new', AuthenticationController.requireLogin(), SubscriptionController.paymentPage webRouter.get '/user/subscription/new', AuthenticationController.requireLogin(), SubscriptionController.paymentPage
webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription
@ -23,14 +24,21 @@ module.exports =
webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup
webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup
webRouter.delete '/subscription/group/email/:email', AuthenticationController.requireLogin(), SubscriptionGroupController.removeEmailInviteFromGroup
webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup
# Team invites
webRouter.post '/subscription/invites', AuthenticationController.requireLogin(),
TeamInvitesController.createInvite
webRouter.get '/subscription/invites/:token/', AuthenticationController.requireLogin(),
TeamInvitesController.viewInvite
webRouter.put '/subscription/invites/:token/', AuthenticationController.requireLogin(),
TeamInvitesController.acceptInvite
webRouter.delete '/subscription/invites/:email/', AuthenticationController.requireLogin(),
TeamInvitesController.revokeInvite
webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage # Routes to join a domain licence team
webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup webRouter.get '/user/subscription/domain/join', AuthenticationController.requireLogin(), DomainLicenceController.join
webRouter.get '/user/subscription/:subscription_id/group/complete-join', AuthenticationController.requireLogin(), SubscriptionGroupController.completeJoin webRouter.post '/user/subscription/domain/join', AuthenticationController.requireLogin(), DomainLicenceController.createInvite
webRouter.get '/user/subscription/:subscription_id/group/successful-join', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSuccessfulJoinPage
#recurly callback #recurly callback
publicApiRouter.post '/user/subscription/callback', SubscriptionController.recurlyNotificationParser, SubscriptionController.recurlyCallback publicApiRouter.post '/user/subscription/callback', SubscriptionController.recurlyNotificationParser, SubscriptionController.recurlyCallback
@ -48,4 +56,3 @@ module.exports =
# Currently used in acceptance tests only, as a way to trigger the syncing logic # Currently used in acceptance tests only, as a way to trigger the syncing logic
publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures

View file

@ -36,14 +36,6 @@ module.exports = SubscriptionUpdater =
return callback(err) return callback(err)
FeaturesUpdater.refreshFeatures user_id, callback FeaturesUpdater.refreshFeatures user_id, callback
addEmailInviteToGroup: (adminUser_id, email, callback) ->
logger.log {adminUser_id, email}, "adding email into mongo subscription"
searchOps =
admin_id: adminUser_id
insertOperation =
"$addToSet": {invited_emails: email}
Subscription.findAndModify searchOps, insertOperation, callback
removeUserFromGroup: (adminUser_id, user_id, callback)-> removeUserFromGroup: (adminUser_id, user_id, callback)->
searchOps = searchOps =
admin_id: adminUser_id admin_id: adminUser_id
@ -55,13 +47,6 @@ module.exports = SubscriptionUpdater =
return callback(err) return callback(err)
FeaturesUpdater.refreshFeatures user_id, callback FeaturesUpdater.refreshFeatures user_id, callback
removeEmailInviteFromGroup: (adminUser_id, email, callback)->
Subscription.update {
admin_id: adminUser_id
}, "$pull": {
invited_emails: email
}, callback
deleteSubscription: (subscription_id, callback = (error) ->) -> deleteSubscription: (subscription_id, callback = (error) ->) ->
SubscriptionLocator.getSubscription subscription_id, (err, subscription) -> SubscriptionLocator.getSubscription subscription_id, (err, subscription) ->
return callback(err) if err? return callback(err) if err?

View file

@ -0,0 +1,58 @@
settings = require "settings-sharelatex"
logger = require("logger-sharelatex")
TeamInvitesHandler = require('./TeamInvitesHandler')
AuthenticationController = require("../Authentication/AuthenticationController")
SubscriptionLocator = require("./SubscriptionLocator")
ErrorController = require("../Errors/ErrorController")
EmailHelper = require("../Helpers/EmailHelper")
module.exports =
createInvite: (req, res, next) ->
teamManagerId = AuthenticationController.getLoggedInUserId(req)
email = EmailHelper.parseEmail(req.body.email)
if !email?
return res.sendStatus(400)
TeamInvitesHandler.createInvite teamManagerId, email, (err, invite) ->
return next(err) if err?
inviteView = { user:
{ email: invite.email, sentAt: invite.sentAt, invite: true }
}
res.json inviteView
viewInvite: (req, res, next) ->
token = req.params.token
userId = AuthenticationController.getLoggedInUserId(req)
TeamInvitesHandler.getInvite token, (err, invite, teamSubscription) ->
return next(err) if err?
if !invite
return ErrorController.notFound(req, res, next)
SubscriptionLocator.getUsersSubscription userId, (err, personalSubscription) ->
return next(err) if err?
res.render "subscriptions/team/invite",
inviterName: invite.inviterName
inviteToken: invite.token
hasPersonalSubscription: personalSubscription?
appName: settings.appName
acceptInvite: (req, res, next) ->
token = req.params.token
userId = AuthenticationController.getLoggedInUserId(req)
TeamInvitesHandler.acceptInvite token, userId, (err, results) ->
return next(err) if err?
res.sendStatus 204
revokeInvite: (req, res) ->
email = EmailHelper.parseEmail(req.params.email)
teamManagerId = AuthenticationController.getLoggedInUserId(req)
if !email?
return res.sendStatus(400)
TeamInvitesHandler.revokeInvite teamManagerId, email, (err, results) ->
return next(err) if err?
res.sendStatus 204

View file

@ -0,0 +1,161 @@
logger = require("logger-sharelatex")
crypto = require("crypto")
async = require("async")
settings = require("settings-sharelatex")
ObjectId = require("mongojs").ObjectId
TeamInvite = require("../../models/TeamInvite").TeamInvite
Subscription = require("../../models/Subscription").Subscription
UserGetter = require("../User/UserGetter")
SubscriptionLocator = require("./SubscriptionLocator")
SubscriptionUpdater = require("./SubscriptionUpdater")
LimitationsManager = require("./LimitationsManager")
EmailHandler = require("../Email/EmailHandler")
EmailHelper = require("../Helpers/EmailHelper")
Errors = require "../Errors/Errors"
module.exports = TeamInvitesHandler =
getInvite: (token, callback) ->
Subscription.findOne 'teamInvites.token': token, (err, subscription) ->
return callback(err) if err?
return callback(new Errors.NotFoundError('team not found')) unless subscription?
invite = subscription.teamInvites.find (i) -> i.token == token
return callback(null, invite, subscription)
createInvite: (teamManagerId, email, callback) ->
email = EmailHelper.parseEmail(email)
return callback(new Error('invalid email')) if !email?
logger.log {teamManagerId, email}, "Creating manager team invite"
UserGetter.getUser teamManagerId, (error, teamManager) ->
return callback(error) if error?
SubscriptionLocator.getUsersSubscription teamManagerId, (error, subscription) ->
return callback(error) if error?
if teamManager.first_name and teamManager.last_name
inviterName = "#{teamManager.first_name} #{teamManager.last_name} (#{teamManager.email})"
else
inviterName = teamManager.email
removeLegacyInvite subscription.id, email, (error) ->
return callback(error) if error?
createInvite(subscription, email, inviterName, callback)
createDomainInvite: (user, licence, callback) ->
email = EmailHelper.parseEmail(user.email)
return callback(new Error('invalid email')) if !email?
logger.log {licence, email: email}, "Creating domain team invite"
inviterName = licence.name.replace(/\s+licence$/i, licence.name)
SubscriptionLocator.getSubscription licence.subscription_id, (error, subscription) ->
return callback(error) if error?
createInvite(subscription, email, inviterName, callback)
acceptInvite: (token, userId, callback) ->
logger.log {userId}, "Accepting invite"
TeamInvitesHandler.getInvite token, (err, invite, subscription) ->
return callback(err) if err?
return callback(new Errors.NotFoundError('invite not found')) unless invite?
SubscriptionUpdater.addUserToGroup subscription.admin_id, userId, (err) ->
return callback(err) if err?
removeInviteFromTeam(subscription.id, invite.email, callback)
revokeInvite: (teamManagerId, email, callback) ->
email = EmailHelper.parseEmail(email)
return callback(new Error('invalid email')) if !email?
logger.log {teamManagerId, email}, "Revoking invite"
SubscriptionLocator.getUsersSubscription teamManagerId, (err, teamSubscription) ->
return callback(err) if err?
removeInviteFromTeam(teamSubscription.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, (err, teams) ->
return callback(err) if err?
async.map teams,
(team, cb) -> TeamInvitesHandler.createInvite(team.admin_id, email, cb)
, callback
createInvite = (subscription, email, inviterName, callback) ->
logger.log {subscriptionId: subscription.id, email, inviterName}, "Creating invite"
checkIfInviteIsPossible subscription, email, (error, possible, reason) ->
return callback(error) if error?
return callback(reason) unless possible
invite = subscription.teamInvites.find (invite) -> invite.email == email
if !invite?
invite = {
email: email
inviterName: inviterName
token: crypto.randomBytes(32).toString("hex")
sentAt: new Date()
}
subscription.teamInvites.push(invite)
else
invite.sentAt = new Date()
subscription.save (error) ->
return callback(error) if error?
opts =
to: email
inviterName: inviterName
acceptInviteUrl: "#{settings.siteUrl}/subscription/invites/#{invite.token}/"
appName: settings.appName
EmailHandler.sendEmail "verifyEmailToJoinTeam", opts, (error) ->
return callback(error, invite)
removeInviteFromTeam = (subscriptionId, email, callback) ->
searchConditions = { _id: new ObjectId(subscriptionId.toString()) }
removeInvite = { $pull: { teamInvites: { email: email } } }
logger.log {subscriptionId, email, searchConditions, removeInvite}, 'removeInviteFromTeam'
async.series [
(cb) -> Subscription.update(searchConditions, removeInvite, cb),
(cb) -> removeLegacyInvite(subscriptionId, email, cb),
], callback
removeLegacyInvite = (subscriptionId, email, callback) ->
Subscription.update({
_id: new ObjectId(subscriptionId.toString())
}, {
$pull: {
invited_emails: email
}
}, callback)
checkIfInviteIsPossible = (subscription, email, callback = (error, possible, reason) -> ) ->
unless subscription.groupPlan
logger.log {subscriptionId: subscription.id},
"can not add members to a subscription that is not in a group plan"
return callback(null, false, wrongPlan: true)
if LimitationsManager.teamHasReachedMemberLimit(subscription)
logger.log {subscriptionId: subscription.id}, "team has reached member limit"
return callback(null, false, limitReached: true)
UserGetter.getUserByAnyEmail email, (error, existingUser) ->
return callback(error) if error?
return callback(null, true) unless existingUser?
existingMember = subscription.member_ids.find (memberId) ->
memberId.toString() == existingUser._id.toString()
if existingMember
logger.log {subscriptionId: subscription.id, email}, "user already in team"
return callback(null, false, alreadyInTeam: true)
else
return callback(null, true)

View file

@ -110,9 +110,9 @@ module.exports = UserController =
logger.err err:err, user_id:user_id, "error getting user for email update" logger.err err:err, user_id:user_id, "error getting user for email update"
return res.send 500 return res.send 500
AuthenticationController.setInSessionUser(req, {email: user.email, first_name: user.first_name, last_name: user.last_name}) AuthenticationController.setInSessionUser(req, {email: user.email, first_name: user.first_name, last_name: user.last_name})
UserHandler.populateGroupLicenceInvite user, (err)-> #need to refresh this in the background UserHandler.populateTeamInvites user, (err)-> #need to refresh this in the background
if err? if err?
logger.err err:err, "error populateGroupLicenceInvite" logger.err err:err, "error populateTeamInvites"
res.sendStatus(200) res.sendStatus(200)
logout : (req, res)-> logout : (req, res)->

View file

@ -1,16 +1,19 @@
SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler") SubscriptionDomainHandler = require("../Subscription/SubscriptionDomainHandler")
NotificationsBuilder = require("../Notifications/NotificationsBuilder") NotificationsBuilder = require("../Notifications/NotificationsBuilder")
SubscriptionGroupHandler = require("../Subscription/SubscriptionGroupHandler") SubscriptionGroupHandler = require("../Subscription/SubscriptionGroupHandler")
TeamInvitesHandler = require("../Subscription/TeamInvitesHandler")
logger = require("logger-sharelatex") logger = require("logger-sharelatex")
module.exports = UserHandler = module.exports = UserHandler =
populateGroupLicenceInvite: (user, callback = ->)-> populateTeamInvites: (user, callback) ->
SubscriptionGroupHandler.convertEmailInvitesToMemberships user.email, user._id, (err) -> UserHandler.notifyDomainLicence user, (err) ->
return callback(err) if err? return callback(err) if err?
TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail(user.email, callback)
logger.log user_id:user._id, "populating any potential group licence invites" notifyDomainLicence: (user, callback = ->)->
logger.log user_id:user._id, "notiying user about a potential domain licence"
licence = SubscriptionDomainHandler.getLicenceUserCanJoin user licence = SubscriptionDomainHandler.getLicenceUserCanJoin user
if !licence? if !licence?
return callback() return callback()
@ -19,11 +22,10 @@ module.exports = UserHandler =
if err? if err?
return callback(err) return callback(err)
else if alreadyPartOfGroup else if alreadyPartOfGroup
logger.log user_id:user._id, "user already part of group, not creating notifcation for them" logger.log user_id:user._id, "user already part of team, not creating notifcation for them"
return callback() return callback()
else else
NotificationsBuilder.groupPlan(user, licence).create(callback) NotificationsBuilder.groupPlan(user, licence).create(callback)
setupLoginData: (user, callback = ->)-> setupLoginData: (user, callback = ->)->
@populateGroupLicenceInvite user, callback @populateTeamInvites user, callback

View file

@ -1,5 +1,6 @@
mongoose = require 'mongoose' mongoose = require 'mongoose'
Settings = require 'settings-sharelatex' Settings = require 'settings-sharelatex'
TeamInviteSchema = require('./TeamInvite').TeamInviteSchema
Schema = mongoose.Schema Schema = mongoose.Schema
ObjectId = Schema.ObjectId ObjectId = Schema.ObjectId
@ -8,6 +9,7 @@ SubscriptionSchema = new Schema
admin_id : {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}} admin_id : {type:ObjectId, ref:'User', index: {unique: true, dropDups: true}}
member_ids : [ type:ObjectId, ref:'User' ] member_ids : [ type:ObjectId, ref:'User' ]
invited_emails: [ String ] invited_emails: [ String ]
teamInvites : [ TeamInviteSchema ]
recurlySubscription_id : String recurlySubscription_id : String
planCode : {type: String} planCode : {type: String}
groupPlan : {type: Boolean, default: false} groupPlan : {type: Boolean, default: false}

View file

@ -0,0 +1,15 @@
mongoose = require 'mongoose'
Settings = require 'settings-sharelatex'
Schema = mongoose.Schema
ObjectId = Schema.ObjectId
TeamInviteSchema = new Schema
email : { type: String, required: true }
token : { type: String }
inviterName : { type: String }
sentAt : { type: Date }
mongoose.model 'TeamInvite', TeamInviteSchema
exports.TeamInvite = mongoose.model 'TeamInvite'
exports.TeamInviteSchema = TeamInviteSchema

View file

@ -0,0 +1,39 @@
extends ../../layout
block scripts
script(type='text/javascript').
window.group_subscription_id = '#{group_subscription_id}'
window.has_personal_subscription = #{has_personal_subscription}
block content
.content.content-alt
.container
.row
.col-md-8.col-md-offset-2
-if (query.expired)
.alert.alert-warning #{translate("email_link_expired")}
.row.row-spaced
.col-md-8.col-md-offset-2.text-center(ng-cloak)
.card(ng-controller="DomainSubscriptionJoinController")
.page-header
h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
div(ng-show="view =='personalSubscription'")
p #{translate("cancel_personal_subscription_first")}
p
a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
| &nbsp;
a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
div(ng-show="view =='domainSubscriptionJoin'")
p #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
p
a.btn.btn-default(href="/project") #{translate("not_now")}
| &nbsp;
a.btn.btn.btn-primary(ng-click="joinGroup()", ng-disabled="inflight") #{translate("verify_email_address")}
div(ng-show="view =='requestSent'")
p #{translate("check_email_to_complete_the_upgrade")}
p
a.btn.btn.btn-primary(href="/project") #{translate("done")}

View file

@ -1,54 +0,0 @@
extends ../../layout
block scripts
script(type='text/javascript').
window.group_subscription_id = '#{group_subscription_id}'
window.has_personal_subscription = #{has_personal_subscription}
block content
.content.content-alt
.container
.row
.col-md-8.col-md-offset-2
-if (query.expired)
.alert.alert-warning #{translate("email_link_expired")}
.row
div &nbsp;
.row
.col-md-8.col-md-offset-2(ng-cloak)
.card(ng-controller="GroupSubscriptionInviteController")
.page-header
h1.text-centered #{translate("you_are_invited_to_group", {groupName:licenceName})}
div(ng-show="view =='personalSubscription'").row.text-centered
div #{translate("cancel_personal_subscription_first")}
.row
.col-md-12 &nbsp;
.row
.col-md-12
a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
span &nbsp;
a.btn.btn.btn-primary(ng-click="cancelSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
div(ng-show="view =='groupSubscriptionInvite'").row.text-centered
.row
.col-md-12 #{translate("group_provides_you_with_premium_account", {groupName:licenceName})}
.row
.col-md-12 &nbsp;
.row
.col-md-12
.text-center
a.btn.btn-default(href="/project") #{translate("not_now")}
span &nbsp;
a.btn.btn.btn-primary(ng-click="joinGroup()", ng-disabled="inflight") #{translate("verify_email_address")}
span(ng-show="view =='requestSent'").row.text-centered.text-center
.row
.col-md-12 #{translate("check_email_to_complete_the_upgrade")}
.row
.col-md-12 &nbsp;
.row
.col-md-12
a.btn.btn.btn-primary(href="/project") #{translate("done")}

View file

@ -1,25 +0,0 @@
extends ../../layout
block scripts
script(type='text/javascript').
window.subscription_id = '#{subscription_id}'
block content
.content.content-alt
.container
.row
.col-md-8.col-md-offset-2(ng-cloak)
.card
.page-header.row.text-centered
h1 #{translate("you_have_joined", {groupName:licenceName})}
div(ng-show="!requestSent").row.text-centered
.row
.span-md-12 #{translate("claim_premium_account", {groupName:licenceName})}
div
.row
.col-md-12 &nbsp;
.row
.span-md-12
a.btn.btn-success(href="/project") #{translate("done")}

View file

@ -32,7 +32,7 @@ block content
.col-md-5 .col-md-5
span.header #{translate("name")} span.header #{translate("name")}
.col-md-2 .col-md-2
span.header #{translate("registered")} span.header #{translate("accepted_invite")}
li.container-fluid( li.container-fluid(
ng-repeat="user in users | orderBy:'email':true", ng-repeat="user in users | orderBy:'email':true",
ng-controller="SubscriptionGroupMemberListItemController" ng-controller="SubscriptionGroupMemberListItemController"
@ -49,8 +49,8 @@ block content
span.name {{ user.first_name }} {{ user.last_name }} span.name {{ user.first_name }} {{ user.last_name }}
.col-md-2 .col-md-2
span.registered span.registered
i.fa.fa-check.text-success(ng-show="!user.holdingAccount") i.fa.fa-check.text-success(ng-show="!user.invite")
i.fa.fa-times(ng-show="user.holdingAccount") i.fa.fa-times(ng-show="user.invite")
li( li(
ng-if="users.length == 0", ng-if="users.length == 0",
ng-cloak ng-cloak

View file

@ -0,0 +1,40 @@
extends ../../layout
block scripts
script(type='text/javascript').
window.teamId = '#{teamId}'
window.hasPersonalSubscription = #{hasPersonalSubscription}
window.inviteToken = '#{inviteToken}'
block content
.content.content-alt.team-invite
.container
.row
.col-md-8.col-md-offset-2
-if (query.expired)
.alert.alert-warning #{translate("email_link_expired")}
.row.row-spaced
.col-md-8.col-md-offset-2.text-center(ng-cloak)
.card(ng-controller="TeamInviteController")
.page-header
h1.text-centered #{translate("invited_to_group", {inviterName: inviterName, appName: appName})}
div(ng-show="view =='personalSubscription'")
p #{translate("cancel_personal_subscription_first")}
p
a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")}
| &nbsp;
a.btn.btn.btn-primary(ng-click="cancelPersonalSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")}
div(ng-show="view =='teamInvite'")
p #{translate("join_team_explanation", {appName: appName})}
p
a.btn.btn-default(href="/project") #{translate("not_now")}
| &nbsp;
a.btn.btn.btn-primary(ng-click="joinTeam()", ng-disabled="inflight") #{translate("accept_invitation")}
div(ng-show="view =='inviteAccepted'")
p #{translate("joined_team", {inviterName: inviterName})}
p
a.btn.btn.btn-primary(href="/project") #{translate("done")}

View file

@ -16,7 +16,8 @@ define [
"main/annual-upgrade" "main/annual-upgrade"
"main/announcements" "main/announcements"
"main/register-users" "main/register-users"
"main/subscription/group-subscription-invite-controller" "main/subscription/domain-subscription-join-controller"
"main/subscription/team-invite-controller"
"main/contact-us" "main/contact-us"
"main/learn" "main/learn"
"analytics/AbTestingManager" "analytics/AbTestingManager"

View file

@ -22,7 +22,7 @@ define [
emails = parseEmails($scope.inputs.emails) emails = parseEmails($scope.inputs.emails)
for email in emails for email in emails
queuedHttp queuedHttp
.post("/subscription/group/user", { .post("/subscription/invites", {
email: email, email: email,
_csrf: window.csrfToken _csrf: window.csrfToken
}) })
@ -34,8 +34,8 @@ define [
$scope.removeMembers = () -> $scope.removeMembers = () ->
for user in $scope.selectedUsers for user in $scope.selectedUsers
do (user) -> do (user) ->
if user.holdingAccount and !user._id? if user.invite and !user._id?
url = "/subscription/group/email/#{encodeURIComponent(user.email)}" url = "/subscription/invites/#{encodeURIComponent(user.email)}"
else else
url = "/subscription/group/user/#{user._id}" url = "/subscription/group/user/#{user._id}"
queuedHttp({ queuedHttp({

View file

@ -1,31 +1,31 @@
define [ define [
"base" "base"
], (App) -> ], (App) ->
App.controller "GroupSubscriptionInviteController", ($scope, $http) -> App.controller "DomainSubscriptionJoinController", ($scope, $http) ->
$scope.inflight = false $scope.inflight = false
if has_personal_subscription if has_personal_subscription
$scope.view = "personalSubscription" $scope.view = "personalSubscription"
else else
$scope.view = "groupSubscriptionInvite" $scope.view = "domainSubscriptionJoin"
$scope.keepPersonalSubscription = -> $scope.keepPersonalSubscription = ->
$scope.view = "groupSubscriptionInvite" $scope.view = "domainSubscriptionJoin"
$scope.cancelSubscription = -> $scope.cancelSubscription = ->
$scope.inflight = true $scope.inflight = true
request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken} request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken}
request.then ()-> request.then ()->
$scope.inflight = false $scope.inflight = false
$scope.view = "groupSubscriptionInvite" $scope.view = "domainSubscriptionJoin"
request.catch ()-> request.catch ()->
console.log "the request failed" console.log "the request failed"
$scope.joinGroup = -> $scope.joinGroup = ->
$scope.view = "requestSent" $scope.view = "requestSent"
$scope.inflight = true $scope.inflight = true
request = $http.post "/user/subscription/#{group_subscription_id}/group/begin-join", {_csrf:window.csrfToken} request = $http.post "/user/subscription/domain/join", {_csrf:window.csrfToken}
request.then (response)-> request.then (response)->
{ status } = response { status } = response
$scope.inflight = false $scope.inflight = false

View file

@ -0,0 +1,35 @@
define [
"base"
], (App) ->
App.controller "TeamInviteController", ($scope, $http) ->
$scope.inflight = false
if hasPersonalSubscription
$scope.view = "personalSubscription"
else
$scope.view = "teamInvite"
$scope.keepPersonalSubscription = ->
$scope.view = "teamInvite"
$scope.cancelPersonalSubscription = ->
$scope.inflight = true
request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken}
request.then ()->
$scope.inflight = false
$scope.view = "teamInvite"
request.catch ()->
console.log "the request failed"
$scope.joinTeam = ->
$scope.inflight = true
request = $http.put "/subscription/invites/#{window.inviteToken}/", {_csrf:window.csrfToken}
request.then (response)->
{ status } = response
$scope.inflight = false
$scope.view = "inviteAccepted"
if status != 200 # assume request worked
$scope.requestSent = false
request.catch ()->
console.log "the request failed"

View file

@ -73,6 +73,10 @@
} }
} }
.team-invite .message {
margin: 3em 0;
}
.capitalised { .capitalised {
text-transform:capitalize; text-transform:capitalize;
} }

View file

@ -286,7 +286,9 @@ describe "LimitationsManager", ->
@subscription = @subscription =
membersLimit: 3 membersLimit: 3
member_ids: ["", ""] member_ids: ["", ""]
invited_emails: ["bob@example.com"] teamInvites: [
{ email: "bob@example.com", sentAt: new Date(), token: "hey" }
]
it "should return true if the limit is hit (including members and invites)", (done)-> it "should return true if the limit is hit (including members and invites)", (done)->
@SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription)

View file

@ -24,10 +24,7 @@ describe "SubscriptionGroupController", ->
@GroupHandler = @GroupHandler =
addUserToGroup: sinon.stub().callsArgWith(2, null, @user) addUserToGroup: sinon.stub().callsArgWith(2, null, @user)
removeUserFromGroup: sinon.stub().callsArgWith(2) removeUserFromGroup: sinon.stub().callsArgWith(2)
removeEmailInviteFromGroup: sinon.stub().callsArgWith(2)
isUserPartOfGroup: sinon.stub() isUserPartOfGroup: sinon.stub()
sendVerificationEmail:sinon.stub()
processGroupVerification:sinon.stub()
getPopulatedListOfMembers: sinon.stub().callsArgWith(1, null, [@user]) getPopulatedListOfMembers: sinon.stub().callsArgWith(1, null, [@user])
@SubscriptionLocator = getUsersSubscription: sinon.stub().callsArgWith(1, null, @subscription) @SubscriptionLocator = getUsersSubscription: sinon.stub().callsArgWith(1, null, @subscription)
@AuthenticationController = @AuthenticationController =
@ -80,16 +77,6 @@ describe "SubscriptionGroupController", ->
done() done()
@Controller.removeUserFromGroup @req, res @Controller.removeUserFromGroup @req, res
describe "removeEmailInviteFromGroup", ->
it "should use the admin id for the logged in user and take the email from the params", (done)->
email = "jo@example.com"
@req.params = email: email
res =
send : =>
@GroupHandler.removeEmailInviteFromGroup.calledWith(@adminUserId, email).should.equal true
done()
@Controller.removeEmailInviteFromGroup @req, res
describe "renderSubscriptionGroupAdminPage", -> describe "renderSubscriptionGroupAdminPage", ->
it "should redirect you if you don't have a group account", (done)-> it "should redirect you if you don't have a group account", (done)->
@subscription.groupPlan = false @subscription.groupPlan = false
@ -109,97 +96,6 @@ describe "SubscriptionGroupController", ->
done() done()
@Controller.renderSubscriptionGroupAdminPage @req, res @Controller.renderSubscriptionGroupAdminPage @req, res
describe "renderGroupInvitePage", ->
describe "with a valid licence", ->
beforeEach ->
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({subscription_id:@subscription_id, adminUser_id:@adminUserId})
it "should render subscriptions/group/invite if not part of group", (done)->
@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, false)
res =
render : (pageName)=>
pageName.should.equal "subscriptions/group/invite"
done()
@Controller.renderGroupInvitePage @req, res
it "should redirect to custom page if is already part of group", (done)->
@GroupHandler.isUserPartOfGroup.callsArgWith(2, null, true)
res =
redirect : (location)=>
location.should.equal "/user/subscription/custom_account"
done()
@Controller.renderGroupInvitePage @req, res
describe "without a valid licence", ->
beforeEach ->
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
it "should send a 500", (done)->
@Controller.renderGroupInvitePage @req, {}
@ErrorsController.notFound.called.should.equal true
done()
describe "beginJoinGroup", ->
describe "with a valid licence", ->
beforeEach ->
@licenceName = "get amazing licence"
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
@GroupHandler.sendVerificationEmail.callsArgWith(3)
it "should ask the SubscriptionGroupHandler to send the verification email", (done)->
res =
sendStatus : (statusCode)=>
statusCode.should.equal 200
@GroupHandler.sendVerificationEmail.calledWith(@subscription_id, @licenceName, @user_email).should.equal true
done()
@Controller.beginJoinGroup @req, res
describe "without a valid licence", ->
beforeEach ->
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
it "should send a 500", (done)->
@Controller.beginJoinGroup @req, {}
@ErrorsController.notFound.called.should.equal true
done()
describe "completeJoin", ->
describe "with a valid licence", ->
beforeEach ->
@GroupHandler.processGroupVerification.callsArgWith(3)
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
it "should redirect to the success page upon processGroupVerification", (done)->
@req.query.token = @token
res =
redirect : (location)=>
@GroupHandler.processGroupVerification.calledWith(@user_email, @subscription_id, @token).should.equal true
location.should.equal "/user/subscription/#{@subscription_id}/group/successful-join"
done()
@Controller.completeJoin @req, res
describe "without a valid licence", ->
it "should send a 500", (done)->
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns(undefined)
@Controller.completeJoin @req, {}
@ErrorsController.notFound.called.should.equal true
done()
it "should redirect to the invited page with querystring if token was not found", (done)->
@SubscriptionDomainHandler.findDomainLicenceBySubscriptionId.returns({name:@licenceName})
@req.query.token = @token
@GroupHandler.processGroupVerification.callsArgWith(3, "token_not_found")
res =
redirect : (location)=>
location.should.equal "/user/subscription/#{@subscription_id}/group/invited?expired=true"
done()
@Controller.completeJoin @req, res
describe "exportGroupCsv", -> describe "exportGroupCsv", ->
beforeEach -> beforeEach ->

View file

@ -30,8 +30,9 @@ describe "SubscriptionGroupHandler", ->
@SubscriptionUpdater = @SubscriptionUpdater =
addUserToGroup: sinon.stub().callsArgWith(2) addUserToGroup: sinon.stub().callsArgWith(2)
removeUserFromGroup: sinon.stub().callsArgWith(2) removeUserFromGroup: sinon.stub().callsArgWith(2)
addEmailInviteToGroup: sinon.stub().callsArgWith(2)
removeEmailInviteFromGroup: sinon.stub().callsArgWith(2) @TeamInvitesHandler =
createInvite: sinon.stub().callsArgWith(2)
@UserGetter = @UserGetter =
getUser: sinon.stub() getUser: sinon.stub()
@ -61,6 +62,7 @@ describe "SubscriptionGroupHandler", ->
"logger-sharelatex": log:-> "logger-sharelatex": log:->
"../User/UserCreator": @UserCreator "../User/UserCreator": @UserCreator
"./SubscriptionUpdater": @SubscriptionUpdater "./SubscriptionUpdater": @SubscriptionUpdater
"./TeamInvitesHandler": @TeamInvitesHandler
"./SubscriptionLocator": @SubscriptionLocator "./SubscriptionLocator": @SubscriptionLocator
"../../models/Subscription": Subscription: @Subscription "../../models/Subscription": Subscription: @Subscription
"../User/UserGetter": @UserGetter "../User/UserGetter": @UserGetter
@ -111,7 +113,7 @@ describe "SubscriptionGroupHandler", ->
it "should add an email invite if no user is found", (done) -> it "should add an email invite if no user is found", (done) ->
@UserGetter.getUserByAnyEmail.callsArgWith(1, null, null) @UserGetter.getUserByAnyEmail.callsArgWith(1, null, null)
@Handler.addUserToGroup @adminUser_id, @newEmail, (err)=> @Handler.addUserToGroup @adminUser_id, @newEmail, (err)=>
@SubscriptionUpdater.addEmailInviteToGroup.calledWith(@adminUser_id, @newEmail).should.equal true @TeamInvitesHandler.createInvite.calledWith(@adminUser_id, @newEmail).should.equal true
done() done()
describe "removeUserFromGroup", -> describe "removeUserFromGroup", ->
@ -183,13 +185,18 @@ describe "SubscriptionGroupHandler", ->
done() done()
it "should return any invited users", (done) -> it "should return any invited users", (done) ->
@subscription.invited_emails = ["jo@example.com", "charlie@example.com"] @subscription.invited_emails = [ "jo@example.com" ]
@subscription.teamInvites = [
{ email: "charlie@example.com" }
]
@Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
users[0].email.should.equal "jo@example.com" users[0].email.should.equal "jo@example.com"
users[0].holdingAccount.should.equal true users[0].invite.should.equal true
users[1].email.should.equal "charlie@example.com" users[1].email.should.equal "charlie@example.com"
users[1].holdingAccount.should.equal true users[1].invite.should.equal true
users.length.should.equal @subscription.invited_emails.length users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length
done() done()
describe "isUserPartOfGroup", -> describe "isUserPartOfGroup", ->
@ -207,64 +214,3 @@ describe "SubscriptionGroupHandler", ->
@Handler.isUserPartOfGroup @user_id, @subscription_id, (err, partOfGroup)-> @Handler.isUserPartOfGroup @user_id, @subscription_id, (err, partOfGroup)->
partOfGroup.should.equal false partOfGroup.should.equal false
done() done()
describe "sendVerificationEmail", ->
beforeEach ->
@token = "secret token"
@subscription_id = "123ed13123"
@licenceName = "great licnece"
@email = "bob@smith.com"
@OneTimeTokenHandler.getNewToken.callsArgWith(2, null, @token)
@EmailHandler.sendEmail.callsArgWith(2)
it "should put a one time token into the email", (done)->
@Handler.sendVerificationEmail @subscription_id, @licenceName, @email, (err)=>
emailOpts = @EmailHandler.sendEmail.args[0][1]
emailOpts.completeJoinUrl.should.equal "#{@settings.siteUrl}/user/subscription/#{@subscription_id}/group/complete-join?token=#{@token}"
emailOpts.to.should.equal @email
emailOpts.group_name.should.equal @licenceName
done()
describe "processGroupVerification", ->
beforeEach ->
@token = "31dDAd2Da"
@SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription)
@Handler.addUserToGroup = sinon.stub().callsArgWith(2)
it "should addUserToGroup", (done)->
@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1, null, @subscription_id)
@Handler.processGroupVerification @email, @subscription_id, @token, (err)=>
@Handler.addUserToGroup.calledWith(@adminUser_id, @email).should.equal true
done()
it "should return token_not_found error if it couldn't get the token", (done)->
@OneTimeTokenHandler.getValueFromTokenAndExpire.callsArgWith(1)
@Handler.processGroupVerification @email, @subscription_id, @token, (err)=>
err.should.equal "token_not_found"
done()
describe "convertEmailInvitesToMemberships", ->
beforeEach ->
@SubscriptionLocator.getGroupsWithEmailInvite = sinon.stub().yields(null, @groups = [{ admin_id: "group-1" }, { admin_id: "group-2" }])
it "should get groups with the email address invited to", (done) ->
@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
@SubscriptionLocator.getGroupsWithEmailInvite.calledWith(@email).should.equal true
done()
it "should remove the email from each group", (done) ->
@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
for group in @groups
@SubscriptionUpdater.removeEmailInviteFromGroup
.calledWith(group.admin_id, @email)
.should.equal true
done()
it "should add the user to each group", (done) ->
@Handler.convertEmailInvitesToMemberships @email, @user_id, (err) =>
for group in @groups
@SubscriptionUpdater.addUserToGroup
.calledWith(group.admin_id, @user_id)
.should.equal true
done()

View file

@ -0,0 +1,267 @@
SandboxedModule = require('sandboxed-module')
should = require('chai').should()
sinon = require 'sinon'
expect = require("chai").expect
querystring = require 'querystring'
modulePath = "../../../../app/js/Features/Subscription/TeamInvitesHandler"
ObjectId = require("mongojs").ObjectId
Errors = require("../../../../app/js/Features/Errors/Errors")
describe "TeamInvitesHandler", ->
beforeEach ->
@manager = {
id: "666666",
first_name: "Daenerys"
last_name: "Targaryen"
email: "daenerys@example.com"
}
@token = "aaaaaaaaaaaaaaaaaaaaaa"
@teamInvite = {
email: "jorah@example.com",
token: @token,
}
@subscription = {
id: "55153a8014829a865bbf700d",
admin_id: @manager.id,
groupPlan: true,
member_ids: [],
teamInvites: [ @teamInvite ],
save: sinon.stub().yields(null),
}
@SubscriptionLocator = {
getUsersSubscription: sinon.stub(),
getSubscription: sinon.stub().yields(null, @subscription)
}
@UserGetter = {
getUser: sinon.stub().yields(),
getUserByAnyEmail: sinon.stub().yields()
}
@SubscriptionUpdater = {
addUserToGroup: sinon.stub().yields()
}
@LimitationsManager = {
teamHasReachedMemberLimit: sinon.stub().returns(false)
}
@Subscription = {
findOne: sinon.stub().yields()
update: sinon.stub().yields()
}
@EmailHandler = {
sendEmail: sinon.stub().yields(null)
}
@newToken = "bbbbbbbbb"
@crypto = {
randomBytes: =>
toString: sinon.stub().returns(@newToken)
}
@UserGetter.getUser.withArgs(@manager.id).yields(null, @manager)
@UserGetter.getUserByAnyEmail.withArgs(@manager.email).yields(null, @manager)
@SubscriptionLocator.getUsersSubscription.yields(null, @subscription)
@Subscription.findOne.yields(null, @subscription)
@TeamInvitesHandler = SandboxedModule.require modulePath, requires:
"logger-sharelatex": { log: -> }
"crypto": @crypto
"settings-sharelatex": { siteUrl: "http://example.com" }
"../../models/TeamInvite": { TeamInvite: @TeamInvite = {} }
"../../models/Subscription": { Subscription: @Subscription }
"../User/UserGetter": @UserGetter
"./SubscriptionLocator": @SubscriptionLocator
"./SubscriptionUpdater": @SubscriptionUpdater
"./LimitationsManager": @LimitationsManager
"../Email/EmailHandler": @EmailHandler
"../Errors/Errors": Errors
describe "getInvite", ->
it "returns the invite if there's one", (done) ->
@TeamInvitesHandler.getInvite @token, (err, invite, subscription) =>
expect(err).to.eq(null)
expect(invite).to.deep.eq(@teamInvite)
expect(subscription).to.deep.eq(@subscription)
done()
it "returns teamNotFound if there's none", (done) ->
@Subscription.findOne = sinon.stub().yields(null, null)
@TeamInvitesHandler.getInvite @token, (err, invite, subscription) ->
expect(err).to.be.instanceof(Errors.NotFoundError)
done()
describe "createInvite", ->
it "adds the team invite to the subscription", (done) ->
@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
expect(err).to.eq(null)
expect(invite.token).to.eq(@newToken)
expect(invite.email).to.eq("john.snow@example.com")
expect(invite.inviterName).to.eq("Daenerys Targaryen (daenerys@example.com)")
expect(@subscription.teamInvites).to.deep.include(invite)
done()
it "sends an email", (done) ->
@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
sinon.match({
to: "john.snow@example.com",
inviterName: "Daenerys Targaryen (daenerys@example.com)",
acceptInviteUrl: "http://example.com/subscription/invites/#{@newToken}/"
})
).should.equal true
done()
it "refreshes the existing invite if the email has already been invited", (done) ->
originalInvite = Object.assign({}, @teamInvite)
@TeamInvitesHandler.createInvite @manager.id, originalInvite.email, (err, invite) =>
expect(err).to.eq(null)
expect(invite).to.exist
expect(@subscription.teamInvites.length).to.eq 1
expect(@subscription.teamInvites).to.deep.include invite
expect(invite.email).to.eq originalInvite.email
@subscription.save.calledOnce.should.eq true
done()
it "removes any legacy invite from the subscription", (done) ->
@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
@Subscription.update.calledWith(
{ _id: new ObjectId("55153a8014829a865bbf700d") },
{ '$pull': { invited_emails: "john.snow@example.com" } }
).should.eq true
done()
describe "createDomainInvite", ->
beforeEach ->
@licence =
subscription_id: @subscription.id
name: "Team Daenerys"
@user =
email: "John.Snow@example.com"
it "adds the team invite to the subscription", (done) ->
@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
expect(err).to.eq(null)
expect(invite.token).to.eq(@newToken)
expect(invite.email).to.eq("john.snow@example.com")
expect(invite.inviterName).to.eq("Team Daenerys")
expect(@subscription.teamInvites).to.deep.include(invite)
done()
it "sends an email", (done) ->
@TeamInvitesHandler.createDomainInvite @user, @licence, (err, invite) =>
@EmailHandler.sendEmail.calledWith("verifyEmailToJoinTeam",
sinon.match({
to: "john.snow@example.com"
inviterName: "Team Daenerys"
acceptInviteUrl: "http://example.com/subscription/invites/#{@newToken}/"
})
).should.equal true
done()
describe "acceptInvite", ->
beforeEach ->
@user = {
id: "123456789",
first_name: "Tyrion",
last_name: "Lannister",
email: "tyrion@example.com"
}
@UserGetter.getUserByAnyEmail.withArgs(@user.email).yields(null, @user)
@subscription.teamInvites.push({
email: "john.snow@example.com",
token: "dddddddd",
inviterName: "Daenerys Targaryen (daenerys@example.com)"
})
it "adds the user to the team", (done) ->
@TeamInvitesHandler.acceptInvite "dddddddd", @user.id, =>
@SubscriptionUpdater.addUserToGroup.calledWith(@manager.id, @user.id).should.eq true
done()
it "removes the invite from the subscription", (done) ->
@TeamInvitesHandler.acceptInvite "dddddddd", @user.id, =>
@Subscription.update.calledWith(
{ _id: new ObjectId("55153a8014829a865bbf700d") },
{ '$pull': { teamInvites: { email: 'john.snow@example.com' } } }
).should.eq true
done()
describe "revokeInvite", ->
it "removes the team invite from the subscription", (done) ->
@TeamInvitesHandler.revokeInvite @manager.id, "jorah@example.com", =>
@Subscription.update.calledWith(
{ _id: new ObjectId("55153a8014829a865bbf700d") },
{ '$pull': { teamInvites: { email: "jorah@example.com" } } }
).should.eq true
@Subscription.update.calledWith(
{ _id: new ObjectId("55153a8014829a865bbf700d") },
{ '$pull': { invited_emails: "jorah@example.com" } }
).should.eq true
done()
describe "createTeamInvitesForLegacyInvitedEmail", (done) ->
beforeEach ->
@subscription.invited_emails = ["eddard@example.com", "robert@example.com"]
@TeamInvitesHandler.createInvite = sinon.stub().yields(null)
@SubscriptionLocator.getGroupsWithEmailInvite = sinon.stub().yields(null, [@subscription])
it "sends an invitation email to addresses in the legacy invited_emails field", (done) ->
@TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail "eddard@example.com", (err, invite) =>
expect(err).not.to.exist
@TeamInvitesHandler.createInvite.calledWith(
@subscription.admin_id,
"eddard@example.com"
).should.eq true
@TeamInvitesHandler.createInvite.callCount.should.eq 1
done()
describe "validation", ->
it "doesn't create an invite if the team limit has been reached", (done) ->
@LimitationsManager.teamHasReachedMemberLimit = sinon.stub().returns(true)
@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
expect(err).to.deep.equal(limitReached: true)
done()
it "doesn't create an invite if the subscription is not in a group plan", (done) ->
@subscription.groupPlan = false
@TeamInvitesHandler.createInvite @manager.id, "John.Snow@example.com", (err, invite) =>
expect(err).to.deep.equal(wrongPlan: true)
done()
it "doesn't create an invite if the user is already part of the team", (done) ->
member = {
id: "1a2b",
_id: "1a2b",
email: "tyrion@example.com"
}
@subscription.member_ids = [member.id]
@UserGetter.getUserByAnyEmail.withArgs(member.email).yields(null, member)
@TeamInvitesHandler.createInvite @manager.id, "tyrion@example.com", (err, invite) =>
expect(err).to.deep.equal(alreadyInTeam: true)
expect(invite).not.to.exist
done()

View file

@ -55,7 +55,7 @@ describe "UserController", ->
@settings = @settings =
siteUrl: "sharelatex.example.com" siteUrl: "sharelatex.example.com"
@UserHandler = @UserHandler =
populateGroupLicenceInvite:sinon.stub().callsArgWith(1) populateTeamInvites: sinon.stub().callsArgWith(1)
@UserSessionsManager = @UserSessionsManager =
trackSession: sinon.stub() trackSession: sinon.stub()
untrackSession: sinon.stub() untrackSession: sinon.stub()
@ -267,12 +267,12 @@ describe "UserController", ->
done() done()
@UserController.updateUserSettings @req, @res @UserController.updateUserSettings @req, @res
it "should call populateGroupLicenceInvite", (done)-> it "should call populateTeamInvites", (done)->
@req.body.email = @newEmail.toUpperCase() @req.body.email = @newEmail.toUpperCase()
@UserUpdater.changeEmailAddress.callsArgWith(2) @UserUpdater.changeEmailAddress.callsArgWith(2)
@res.sendStatus = (code)=> @res.sendStatus = (code)=>
code.should.equal 200 code.should.equal 200
@UserHandler.populateGroupLicenceInvite.calledWith(@user).should.equal true @UserHandler.populateTeamInvites.calledWith(@user).should.equal true
done() done()
@UserController.updateUserSettings @req, @res @UserController.updateUserSettings @req, @res

View file

@ -19,27 +19,37 @@ describe "UserHandler", ->
@SubscriptionGroupHandler = @SubscriptionGroupHandler =
isUserPartOfGroup:sinon.stub() isUserPartOfGroup:sinon.stub()
convertEmailInvitesToMemberships: sinon.stub().callsArgWith(2)
@createStub = sinon.stub().callsArgWith(0) @createStub = sinon.stub().callsArgWith(0)
@NotificationsBuilder = @NotificationsBuilder =
groupPlan:sinon.stub().returns({create:@createStub}) groupPlan:sinon.stub().returns({create:@createStub})
@TeamInvitesHandler =
createTeamInvitesForLegacyInvitedEmail: sinon.stub().yields()
@UserHandler = SandboxedModule.require modulePath, requires: @UserHandler = SandboxedModule.require modulePath, requires:
"logger-sharelatex": @logger = { log: sinon.stub() } "logger-sharelatex": @logger = { log: sinon.stub() }
"../Notifications/NotificationsBuilder":@NotificationsBuilder "../Notifications/NotificationsBuilder":@NotificationsBuilder
"../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler "../Subscription/SubscriptionDomainHandler":@SubscriptionDomainHandler
"../Subscription/SubscriptionGroupHandler":@SubscriptionGroupHandler "../Subscription/SubscriptionGroupHandler":@SubscriptionGroupHandler
"../Subscription/TeamInvitesHandler": @TeamInvitesHandler
describe "populateGroupLicenceInvite", -> describe "populateTeamInvites", ->
beforeEach (done)->
@UserHandler.notifyDomainLicence = sinon.stub().yields()
@UserHandler.populateTeamInvites @user, done
it "notifies the user about domain licences zzzzz", ->
@UserHandler.notifyDomainLicence.calledWith(@user).should.eq true
it "notifies the user about legacy team invites", ->
@TeamInvitesHandler.createTeamInvitesForLegacyInvitedEmail
.calledWith(@user.email).should.eq true
describe "notifyDomainLicence", ->
describe "no licence", -> describe "no licence", ->
beforeEach (done)-> beforeEach (done)->
@SubscriptionDomainHandler.getLicenceUserCanJoin.returns() @SubscriptionDomainHandler.getLicenceUserCanJoin.returns()
@UserHandler.populateGroupLicenceInvite @user, done @UserHandler.populateTeamInvites @user, done
it "should call convertEmailInvitesToMemberships", ->
@SubscriptionGroupHandler.convertEmailInvitesToMemberships
.calledWith(@user.email, @user._id)
.should.equal true
it "should not call NotificationsBuilder", (done)-> it "should not call NotificationsBuilder", (done)->
@NotificationsBuilder.groupPlan.called.should.equal false @NotificationsBuilder.groupPlan.called.should.equal false
@ -53,28 +63,18 @@ describe "UserHandler", ->
beforeEach (done)-> beforeEach (done)->
@SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence) @SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence)
@SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, false) @SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, false)
@UserHandler.populateGroupLicenceInvite @user, done @UserHandler.populateTeamInvites @user, done
it "should create notifcation", (done)-> it "should create notifcation", (done)->
@NotificationsBuilder.groupPlan.calledWith(@user, @licence).should.equal true @NotificationsBuilder.groupPlan.calledWith(@user, @licence).should.equal true
done() done()
it "should call convertEmailInvitesToMemberships", ->
@SubscriptionGroupHandler.convertEmailInvitesToMemberships
.calledWith(@user.email, @user._id)
.should.equal true
describe "with matching licence user is already in", -> describe "with matching licence user is already in", ->
beforeEach (done)-> beforeEach (done)->
@SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence) @SubscriptionDomainHandler.getLicenceUserCanJoin.returns(@licence)
@SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, true) @SubscriptionGroupHandler.isUserPartOfGroup.callsArgWith(2, null, true)
@UserHandler.populateGroupLicenceInvite @user, done @UserHandler.populateTeamInvites @user, done
it "should create notifcation", (done)-> it "should create notifcation", (done)->
@NotificationsBuilder.groupPlan.called.should.equal false @NotificationsBuilder.groupPlan.called.should.equal false
done() done()
it "should call convertEmailInvitesToMemberships", ->
@SubscriptionGroupHandler.convertEmailInvitesToMemberships
.calledWith(@user.email, @user._id)
.should.equal true