Przeglądaj źródła

Merge pull request #13850 from overleaf/ab-invite-enrollment

[web] Managed users - combined invite/surrender flow

GitOrigin-RevId: 70cb0d81e0019eac69a4a565377447bb6d1a1823
June Kelly 3 lat temu
rodzic
commit
4f8852feaa

+ 50 - 17
services/web/app/src/Features/Subscription/TeamInvitesController.js

@@ -6,6 +6,8 @@ const ErrorController = require('../Errors/ErrorController')
 const EmailHelper = require('../Helpers/EmailHelper')
 const UserGetter = require('../User/UserGetter')
 const { expressify } = require('../../util/promises')
+const HttpErrorHandler = require('../Errors/HttpErrorHandler')
+const PermissionsManager = require('../Authorization/PermissionsManager')
 
 function createInvite(req, res, next) {
   const teamManagerId = SessionManager.getLoggedInUserId(req.session)
@@ -53,11 +55,14 @@ async function viewInvite(req, res, next) {
   const { token } = req.params
   const userId = SessionManager.getLoggedInUserId(req.session)
 
-  const { invite } = await TeamInvitesHandler.promises.getInvite(token)
+  const { invite, subscription } = await TeamInvitesHandler.promises.getInvite(
+    token
+  )
   if (!invite) {
     return ErrorController.notFound(req, res)
   }
 
+  let validationStatus = new Map()
   if (userId) {
     const personalSubscription =
       await SubscriptionLocator.promises.getUsersSubscription(userId)
@@ -69,19 +74,51 @@ async function viewInvite(req, res, next) {
       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,
-    })
+    if (subscription?.groupPolicy) {
+      if (!subscription.populated('groupPolicy')) {
+        await subscription.populate('groupPolicy')
+      }
+
+      const user = await UserGetter.promises.getUser(userId)
+
+      if (
+        user.enrollment?.managedBy &&
+        user.enrollment?.managedBy.toString() !== subscription._id.toString()
+      ) {
+        return HttpErrorHandler.forbidden(
+          req,
+          res,
+          'User is already managed by a different subscription'
+        )
+      }
+
+      validationStatus =
+        await PermissionsManager.promises.getUserValidationStatus(
+          user,
+          subscription.groupPolicy
+        )
+
+      return res.render('subscriptions/team/invite-managed', {
+        inviterName: invite.inviterName,
+        inviteToken: invite.token,
+        expired: req.query.expired,
+        validationStatus: Object.fromEntries(validationStatus),
+      })
+    } else {
+      return 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
     )
 
-    res.render('subscriptions/team/invite_logged_out', {
+    return res.render('subscriptions/team/invite_logged_out', {
       inviterName: invite.inviterName,
       inviteToken: invite.token,
       appName: settings.appName,
@@ -91,16 +128,12 @@ async function viewInvite(req, res, next) {
   }
 }
 
-function acceptInvite(req, res, next) {
+async function acceptInvite(req, res, next) {
   const { token } = req.params
   const userId = SessionManager.getLoggedInUserId(req.session)
 
-  TeamInvitesHandler.acceptInvite(token, userId, function (err, results) {
-    if (err) {
-      return next(err)
-    }
-    res.sendStatus(204)
-  })
+  await TeamInvitesHandler.promises.acceptInvite(token, userId)
+  res.sendStatus(204)
 }
 
 function revokeInvite(req, res, next) {
@@ -127,6 +160,6 @@ function revokeInvite(req, res, next) {
 module.exports = {
   createInvite,
   viewInvite: expressify(viewInvite),
-  acceptInvite,
+  acceptInvite: expressify(acceptInvite),
   revokeInvite,
 }

+ 151 - 212
services/web/app/src/Features/Subscription/TeamInvitesHandler.js

@@ -1,6 +1,5 @@
 const logger = require('@overleaf/logger')
 const crypto = require('crypto')
-const async = require('async')
 
 const settings = require('@overleaf/settings')
 const { ObjectId } = require('mongodb')
@@ -11,220 +10,164 @@ const UserGetter = require('../User/UserGetter')
 const SubscriptionLocator = require('./SubscriptionLocator')
 const SubscriptionUpdater = require('./SubscriptionUpdater')
 const LimitationsManager = require('./LimitationsManager')
+const ManagedUsersHandler = require('./ManagedUsersHandler')
 
 const EmailHandler = require('../Email/EmailHandler')
 const EmailHelper = require('../Helpers/EmailHelper')
 
 const Errors = require('../Errors/Errors')
-const { promisifyMultiResult, promisify } = require('../../util/promises')
-
-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'))
-      }
-
-      const invite = subscription.teamInvites.find(i => i.token === token)
-      callback(null, invite, subscription)
-    }
-  )
+const { callbackify, callbackifyMultiResult } = require('../../util/promises')
+
+async function getInvite(token) {
+  const subscription = await Subscription.findOne({
+    'teamInvites.token': token,
+  })
+  if (!subscription) {
+    throw new Errors.NotFoundError('team not found')
+  }
+
+  const invite = subscription.teamInvites.find(i => i.token === token)
+  return { invite, subscription }
 }
 
-function createInvite(teamManagerId, subscription, email, callback) {
+async function createInvite(teamManagerId, subscription, email) {
   email = EmailHelper.parseEmail(email)
   if (!email) {
-    return callback(new Error('invalid email'))
+    throw new Error('invalid email')
   }
-  UserGetter.getUser(teamManagerId, function (error, teamManager) {
-    if (error) {
-      return callback(error)
-    }
+  const teamManager = await UserGetter.promises.getUser(teamManagerId)
 
-    _removeLegacyInvite(subscription.id, email, function (error) {
-      if (error) {
-        return callback(error)
-      }
-      _createInvite(subscription, email, teamManager, callback)
-    })
-  })
+  await _removeLegacyInvite(subscription.id, email)
+  return _createInvite(subscription, email, teamManager)
 }
 
-function importInvite(
-  subscription,
-  inviterName,
-  email,
-  token,
-  sentAt,
-  callback
-) {
-  _checkIfInviteIsPossible(
+async function importInvite(subscription, inviterName, email, token, sentAt) {
+  const { possible, reason } = await _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)
-    }
+    email
   )
+  if (!possible) {
+    throw reason
+  }
+  subscription.teamInvites.push({
+    email,
+    inviterName,
+    token,
+    sentAt,
+  })
+
+  return subscription.save()
 }
 
-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'))
-    }
+async function acceptInvite(token, userId) {
+  const { invite, subscription } = await getInvite(token)
+  if (!invite) {
+    throw new Errors.NotFoundError('invite not found')
+  }
 
-    SubscriptionUpdater.addUserToGroup(
-      subscription._id,
-      userId,
-      function (err) {
-        if (err) {
-          return callback(err)
-        }
+  await SubscriptionUpdater.promises.addUserToGroup(subscription._id, userId)
 
-        _removeInviteFromTeam(subscription.id, invite.email, callback)
-      }
+  if (subscription.groupPolicy) {
+    await ManagedUsersHandler.promises.enrollInSubscription(
+      userId,
+      subscription
     )
-  })
+  }
+
+  await _removeInviteFromTeam(subscription.id, invite.email)
 }
 
-function revokeInvite(teamManagerId, subscription, email, callback) {
+async function revokeInvite(teamManagerId, subscription, email) {
   email = EmailHelper.parseEmail(email)
   if (!email) {
-    return callback(new Error('invalid email'))
+    throw new Error('invalid email')
   }
-  _removeInviteFromTeam(subscription.id, email, callback)
+  await _removeInviteFromTeam(subscription.id, email)
 }
 
 // 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 function createTeamInvitesForLegacyInvitedEmail(email) {
+  const teams = await SubscriptionLocator.promises.getGroupsWithEmailInvite(
+    email
+  )
 
-    async.map(
-      teams,
-      (team, cb) => createInvite(team.admin_id, team, email, cb),
-      callback
-    )
-  })
+  return Promise.all(
+    teams.map(team => createInvite(team.admin_id, team, email))
+  )
 }
 
-function _createInvite(subscription, email, inviter, callback) {
-  _checkIfInviteIsPossible(
+async function _createInvite(subscription, email, inviter) {
+  const { possible, reason } = await _checkIfInviteIsPossible(
     subscription,
-    email,
-    function (error, possible, reason) {
-      if (error) {
-        return callback(error)
-      }
-      if (!possible) {
-        return callback(reason)
-      }
-
-      // don't send invites when inviting self; add user directly to the group
-      const isInvitingSelf = inviter.emails.some(
-        emailData => emailData.email === email
-      )
-      if (isInvitingSelf) {
-        return SubscriptionUpdater.addUserToGroup(
-          subscription._id,
-          inviter._id,
-          err => {
-            if (err) {
-              return callback(err)
-            }
-
-            // legacy: remove any invite that might have been created in the past
-            _removeInviteFromTeam(subscription._id, email, error => {
-              const inviteUserData = {
-                email: inviter.email,
-                first_name: inviter.first_name,
-                last_name: inviter.last_name,
-                invite: false,
-              }
-              callback(error, inviteUserData)
-            })
-          }
-        )
-      }
-
-      const inviterName = _getInviterName(inviter)
-      let invite = subscription.teamInvites.find(
-        invite => invite.email === email
-      )
-
-      if (invite) {
-        invite = invite.toObject()
-        invite.sentAt = new Date()
-      } else {
-        invite = {
-          email,
-          inviterName,
-          token: crypto.randomBytes(32).toString('hex'),
-          sentAt: new Date(),
-        }
-        subscription.teamInvites.push(invite)
-      }
-
-      subscription.save(function (error) {
-        if (error) {
-          return callback(error)
-        }
-
-        const opts = {
-          to: email,
-          inviter,
-          acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
-          appName: settings.appName,
-        }
-        EmailHandler.sendEmail('verifyEmailToJoinTeam', opts, error => {
-          Object.assign(invite, { invite: true })
-          callback(error, invite)
-        })
-      })
-    }
+    email
   )
+
+  if (!possible) {
+    throw reason
+  }
+
+  // don't send invites when inviting self; add user directly to the group
+  const isInvitingSelf = inviter.emails.some(
+    emailData => emailData.email === email
+  )
+  if (isInvitingSelf) {
+    await SubscriptionUpdater.promises.addUserToGroup(
+      subscription._id,
+      inviter._id
+    )
+
+    // legacy: remove any invite that might have been created in the past
+    await _removeInviteFromTeam(subscription._id, email)
+
+    return {
+      email: inviter.email,
+      first_name: inviter.first_name,
+      last_name: inviter.last_name,
+      invite: false,
+    }
+  }
+
+  const inviterName = _getInviterName(inviter)
+  let invite = subscription.teamInvites.find(invite => invite.email === email)
+
+  if (invite) {
+    invite = invite.toObject()
+    invite.sentAt = new Date()
+  } else {
+    invite = {
+      email,
+      inviterName,
+      token: crypto.randomBytes(32).toString('hex'),
+      sentAt: new Date(),
+    }
+    subscription.teamInvites.push(invite)
+  }
+
+  await subscription.save()
+
+  const opts = {
+    to: email,
+    inviter,
+    acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
+    appName: settings.appName,
+  }
+  await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts)
+  Object.assign(invite, { invite: true })
+  return invite
 }
 
-function _removeInviteFromTeam(subscriptionId, email, callback) {
+async 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),
-    ],
-    callback
-  )
+  await Subscription.updateOne(searchConditions, removeInvite)
+  await _removeLegacyInvite(subscriptionId, email)
 }
 
-const _removeLegacyInvite = (subscriptionId, email, callback) =>
-  Subscription.updateOne(
+async function _removeLegacyInvite(subscriptionId, email) {
+  await Subscription.updateOne(
     {
       _id: new ObjectId(subscriptionId.toString()),
     },
@@ -232,17 +175,17 @@ const _removeLegacyInvite = (subscriptionId, email, callback) =>
       $pull: {
         invited_emails: email,
       },
-    },
-    callback
+    }
   )
+}
 
-function _checkIfInviteIsPossible(subscription, email, callback) {
+async function _checkIfInviteIsPossible(subscription, email) {
   if (!subscription.groupPlan) {
     logger.debug(
       { subscriptionId: subscription.id },
       'can not add members to a subscription that is not in a group plan'
     )
-    return callback(null, false, { wrongPlan: true })
+    return { possible: false, reason: { wrongPlan: true } }
   }
 
   if (LimitationsManager.teamHasReachedMemberLimit(subscription)) {
@@ -250,31 +193,27 @@ function _checkIfInviteIsPossible(subscription, email, callback) {
       { subscriptionId: subscription.id },
       'team has reached member limit'
     )
-    return callback(null, false, { limitReached: true })
+    return { possible: false, reason: { limitReached: true } }
   }
 
-  UserGetter.getUserByAnyEmail(email, function (error, existingUser) {
-    if (error) {
-      return callback(error)
-    }
-    if (!existingUser) {
-      return callback(null, true)
-    }
+  const existingUser = await UserGetter.promises.getUserByAnyEmail(email)
+  if (!existingUser) {
+    return { possible: true }
+  }
 
-    const existingMember = subscription.member_ids.find(
-      memberId => memberId.toString() === existingUser._id.toString()
-    )
+  const existingMember = subscription.member_ids.find(
+    memberId => memberId.toString() === existingUser._id.toString()
+  )
 
-    if (existingMember) {
-      logger.debug(
-        { subscriptionId: subscription.id, email },
-        'user already in team'
-      )
-      callback(null, false, { alreadyInTeam: true })
-    } else {
-      callback(null, true)
-    }
-  })
+  if (existingMember) {
+    logger.debug(
+      { subscriptionId: subscription.id, email },
+      'user already in team'
+    )
+    return { possible: false, reason: { alreadyInTeam: true } }
+  } else {
+    return { possible: true }
+  }
 }
 
 function _getInviterName(inviter) {
@@ -289,20 +228,20 @@ function _getInviterName(inviter) {
 }
 
 module.exports = {
-  getInvite,
-  createInvite,
-  importInvite,
-  acceptInvite,
-  revokeInvite,
-  createTeamInvitesForLegacyInvitedEmail,
+  getInvite: callbackifyMultiResult(getInvite, ['invite', 'subscription']),
+  createInvite: callbackify(createInvite),
+  importInvite: callbackify(importInvite),
+  acceptInvite: callbackify(acceptInvite),
+  revokeInvite: callbackify(revokeInvite),
+  createTeamInvitesForLegacyInvitedEmail: callbackify(
+    createTeamInvitesForLegacyInvitedEmail
+  ),
   promises: {
-    getInvite: promisifyMultiResult(getInvite, ['invite', 'subscription']),
-    createInvite: promisify(createInvite),
-    importInvite: promisify(importInvite),
-    acceptInvite: promisify(acceptInvite),
-    revokeInvite: promisify(revokeInvite),
-    createTeamInvitesForLegacyInvitedEmail: promisify(
-      createTeamInvitesForLegacyInvitedEmail
-    ),
+    getInvite,
+    createInvite,
+    importInvite,
+    acceptInvite,
+    revokeInvite,
+    createTeamInvitesForLegacyInvitedEmail,
   },
 }

+ 14 - 0
services/web/app/views/subscriptions/team/invite-managed.pug

@@ -0,0 +1,14 @@
+extends ../../layout-marketing
+
+block entrypointVar
+	- entrypoint = 'pages/user/subscription/invite-managed'
+
+block append meta
+	meta(name="ol-inviteToken" content=inviteToken)
+	meta(name="ol-inviterName" content=inviterName)
+	meta(name="ol-expired" data-type="boolean" content=expired)
+	meta(name="ol-alreadyEnrolled" data-type="boolean" content=alreadyEnrolled)
+	meta(name="ol-validationStatus" data-type="json" content=validationStatus)
+
+block content
+	main.content.content-alt.team-invite#invite-managed-root

+ 5 - 4
services/web/app/views/subscriptions/team/invite.pug

@@ -28,10 +28,11 @@ block content
 
 						div(ng-show="view =='teamInvite'")
 							p #{translate("join_team_explanation", {appName: appName})}
-							p
-								a.btn.btn-secondary(href="/project") #{translate("not_now")}
-								|  
-								a.btn.btn.btn-primary(ng-click="joinTeam()", ng-disabled="inflight") #{translate("accept_invitation")} 
+							if (!expired)
+								p
+									a.btn.btn-secondary(href="/project") #{translate("not_now")}
+									|  
+									a.btn.btn.btn-primary(ng-click="joinTeam()", ng-disabled="inflight") #{translate("accept_invitation")} 
 
 						div(ng-show="view =='inviteAccepted'")
 							p(ng-non-bindable) #{translate("joined_team", {inviterName: inviterName})}

+ 1 - 0
services/web/config/settings.defaults.js

@@ -809,6 +809,7 @@ module.exports = {
     editorLeftMenuManageTemplate: [],
     oauth2Server: [],
     managedGroupSubscriptionEnrollmentNotification: [],
+    managedGroupEnrollmentInvite: [],
   },
 
   moduleImportSequence: ['launchpad', 'server-ce-scripts', 'user-activate'],

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -197,6 +197,7 @@
   "current_file": "",
   "current_password": "",
   "currently_seeing_only_24_hrs_history": "",
+  "currently_signed_in_as_x": "",
   "currently_subscribed_to_plan": "",
   "customize_your_group_subscription": "",
   "customizing_figures": "",
@@ -280,6 +281,7 @@
   "educational_percent_discount_applied": "",
   "email": "",
   "email_limit_reached": "",
+  "email_link_expired": "",
   "email_or_password_wrong_try_again": "",
   "emails_and_affiliations_explanation": "",
   "emails_and_affiliations_title": "",
@@ -581,6 +583,7 @@
   "log_entry_maximum_entries_title": "",
   "log_hint_extra_info": "",
   "log_in_with_primary_email_address": "",
+  "log_out_lowercase_dot": "",
   "log_viewer_error": "",
   "login_to_transfer_account": "",
   "login_with_service": "",
@@ -803,6 +806,8 @@
   "read_only": "",
   "read_only_token": "",
   "read_write_token": "",
+  "ready_to_join_x": "",
+  "ready_to_join_x_in_group_y": "",
   "realtime_track_changes": "",
   "reauthorize_github_account": "",
   "recaptcha_conditions": "",

+ 12 - 0
services/web/frontend/js/features/subscription/components/invite-managed-root.tsx

@@ -0,0 +1,12 @@
+import { JSXElementConstructor } from 'react'
+import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
+
+const [inviteManagedModule] = importOverleafModules(
+  'managedGroupEnrollmentInvite'
+)
+const InviteManaged: JSXElementConstructor<Record<string, never>> =
+  inviteManagedModule?.import.default
+
+export default function InviteManagedRoot() {
+  return <InviteManaged />
+}

+ 8 - 0
services/web/frontend/js/pages/user/subscription/invite-managed.js

@@ -0,0 +1,8 @@
+import './base'
+import ReactDOM from 'react-dom'
+import InvitedManagedRoot from '../../../features/subscription/components/invite-managed-root'
+
+const element = document.getElementById('invite-managed-root')
+if (element) {
+  ReactDOM.render(<InvitedManagedRoot />, element)
+}

+ 1 - 1
services/web/frontend/stylesheets/modules/managed-users.less

@@ -25,7 +25,7 @@
 
       .icon {
         display: flex;
-        flex: 1 1 10%;
+        flex: 0 0 32px;
         > span {
           font-size: 16px;
         }

+ 4 - 0
services/web/locales/en.json

@@ -313,6 +313,7 @@
   "current_password": "Current Password",
   "current_session": "Current Session",
   "currently_seeing_only_24_hrs_history": "You’re currently seeing the last 24 hours of changes in this project.",
+  "currently_signed_in_as_x": "Currently signed in as <0>__userEmail__</0>.",
   "currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__</0> plan.",
   "custom_resource_portal": "Custom resource portal",
   "custom_resource_portal_info": "You can have your own custom portal page on Overleaf. This is a great place for your users to find out more about Overleaf, access templates, FAQs and Help resources, and sign up to Overleaf.",
@@ -924,6 +925,7 @@
   "log_in_with_primary_email_address": "This will be the email address to use if you log in with an email address and password. Important __appName__ notifications will be sent to this email address.",
   "log_out": "Log Out",
   "log_out_from": "Log out from __email__",
+  "log_out_lowercase_dot": "Log out.",
   "log_viewer_error": "There was a problem displaying this project’s compilation errors and logs.",
   "logged_in_with_email": "You are currently logged in to <b>__appName__</b> with the email <b>__email__</b>.",
   "logging_in": "Logging in",
@@ -1266,6 +1268,8 @@
   "read_only": "Read Only",
   "read_only_token": "Read-Only Token",
   "read_write_token": "Read-Write Token",
+  "ready_to_join_x": "You’re ready to join __inviterName__",
+  "ready_to_join_x_in_group_y": "You’re ready to join __inviterName__ in __groupName__",
   "real_time_track_changes": "Real-time <0>track-changes</0>",
   "realtime_collab": "Real-time collaboration",
   "realtime_collab_info": "When you’re working together, you can see your collaborators’ cursors and their changes in real time, so everyone always has the latest version.",

+ 41 - 27
services/web/test/unit/src/Subscription/TeamInvitesHandlerTests.js

@@ -33,21 +33,27 @@ describe('TeamInvitesHandler', function () {
       groupPlan: true,
       member_ids: [],
       teamInvites: [this.teamInvite],
-      save: sinon.stub().yields(null),
+      save: sinon.stub().resolves(),
     }
 
     this.SubscriptionLocator = {
-      getUsersSubscription: sinon.stub(),
-      getSubscription: sinon.stub().yields(null, this.subscription),
+      promises: {
+        getUsersSubscription: sinon.stub(),
+        getSubscription: sinon.stub().resolves(this.subscription),
+      },
     }
 
     this.UserGetter = {
-      getUser: sinon.stub().yields(),
-      getUserByAnyEmail: sinon.stub().yields(),
+      promises: {
+        getUser: sinon.stub().resolves(),
+        getUserByAnyEmail: sinon.stub().resolves(),
+      },
     }
 
     this.SubscriptionUpdater = {
-      addUserToGroup: sinon.stub().yields(),
+      promises: {
+        addUserToGroup: sinon.stub().resolves(),
+      },
     }
 
     this.LimitationsManager = {
@@ -55,12 +61,20 @@ describe('TeamInvitesHandler', function () {
     }
 
     this.Subscription = {
-      findOne: sinon.stub().yields(),
-      updateOne: sinon.stub().yields(),
+      findOne: sinon.stub().resolves(),
+      updateOne: sinon.stub().resolves(),
     }
 
     this.EmailHandler = {
-      sendEmail: sinon.stub().yields(null),
+      promises: {
+        sendEmail: sinon.stub().resolves(null),
+      },
+    }
+
+    this.ManagedUsersHandler = {
+      promises: {
+        enrollInSubscription: sinon.stub().resolves(),
+      },
     }
 
     this.newToken = 'bbbbbbbbb'
@@ -71,18 +85,17 @@ describe('TeamInvitesHandler', function () {
       },
     }
 
-    this.UserGetter.getUser
+    this.UserGetter.promises.getUser
       .withArgs(this.manager._id)
-      .yields(null, this.manager)
-    this.UserGetter.getUserByAnyEmail
+      .resolves(this.manager)
+    this.UserGetter.promises.getUserByAnyEmail
       .withArgs(this.manager.email)
-      .yields(null, this.manager)
+      .resolves(this.manager)
 
-    this.SubscriptionLocator.getUsersSubscription.yields(
-      null,
+    this.SubscriptionLocator.promises.getUsersSubscription.resolves(
       this.subscription
     )
-    this.Subscription.findOne.yields(null, this.subscription)
+    this.Subscription.findOne.resolves(this.subscription)
 
     this.TeamInvitesHandler = SandboxedModule.require(modulePath, {
       requires: {
@@ -96,6 +109,7 @@ describe('TeamInvitesHandler', function () {
         './SubscriptionUpdater': this.SubscriptionUpdater,
         './LimitationsManager': this.LimitationsManager,
         '../Email/EmailHandler': this.EmailHandler,
+        './ManagedUsersHandler': this.ManagedUsersHandler,
       },
     })
   })
@@ -114,7 +128,7 @@ describe('TeamInvitesHandler', function () {
     })
 
     it("returns teamNotFound if there's none", function (done) {
-      this.Subscription.findOne = sinon.stub().yields(null, null)
+      this.Subscription.findOne = sinon.stub().resolves(null)
 
       this.TeamInvitesHandler.getInvite(
         this.token,
@@ -152,7 +166,7 @@ describe('TeamInvitesHandler', function () {
         this.subscription,
         'John.Snow@example.com',
         (err, invite) => {
-          this.EmailHandler.sendEmail
+          this.EmailHandler.promises.sendEmail
             .calledWith(
               'verifyEmailToJoinTeam',
               sinon.match({
@@ -214,7 +228,7 @@ describe('TeamInvitesHandler', function () {
         this.manager.email,
         (err, invite) => {
           sinon.assert.calledWith(
-            this.SubscriptionUpdater.addUserToGroup,
+            this.SubscriptionUpdater.promises.addUserToGroup,
             this.subscription._id,
             this.manager._id
           )
@@ -266,9 +280,9 @@ describe('TeamInvitesHandler', function () {
         email: 'tyrion@example.com',
       }
 
-      this.UserGetter.getUserByAnyEmail
+      this.UserGetter.promises.getUserByAnyEmail
         .withArgs(this.user.email)
-        .yields(null, this.user)
+        .resolves(this.user)
 
       this.subscription.teamInvites.push({
         email: 'john.snow@example.com',
@@ -279,7 +293,7 @@ describe('TeamInvitesHandler', function () {
 
     it('adds the user to the team', function (done) {
       this.TeamInvitesHandler.acceptInvite('dddddddd', this.user.id, () => {
-        this.SubscriptionUpdater.addUserToGroup
+        this.SubscriptionUpdater.promises.addUserToGroup
           .calledWith(this.subscription._id, this.user.id)
           .should.eq(true)
         done()
@@ -331,10 +345,10 @@ describe('TeamInvitesHandler', function () {
         'eddard@example.com',
         'robert@example.com',
       ]
-      this.TeamInvitesHandler.createInvite = sinon.stub().yields(null)
-      this.SubscriptionLocator.getGroupsWithEmailInvite = sinon
+      this.TeamInvitesHandler.createInvite = sinon.stub().resolves(null)
+      this.SubscriptionLocator.promises.getGroupsWithEmailInvite = sinon
         .stub()
-        .yields(null, [this.subscription])
+        .resolves([this.subscription])
     })
 
     it('sends an invitation email to addresses in the legacy invited_emails field', function (done) {
@@ -396,9 +410,9 @@ describe('TeamInvitesHandler', function () {
       }
 
       this.subscription.member_ids = [member.id]
-      this.UserGetter.getUserByAnyEmail
+      this.UserGetter.promises.getUserByAnyEmail
         .withArgs(member.email)
-        .yields(null, member)
+        .resolves(member)
 
       this.TeamInvitesHandler.createInvite(
         this.manager._id,