Procházet zdrojové kódy

Merge pull request #29881 from overleaf/jel-domain-capture-email-invite-cta

[web] Update CTA for domain capture group invites

GitOrigin-RevId: addaa81c443da63124b7841f087e34145fe3bfe6
Jessica Lawshe před 10 měsíci
rodič
revize
2e3ddb0965

+ 15 - 1
services/web/app/src/Features/Subscription/TeamInvitesController.mjs

@@ -269,11 +269,25 @@ async function resendInvite(req, res, next) {
     return await createInvite(req, res)
     return await createInvite(req, res)
   }
   }
 
 
+  let acceptInviteUrl
+  if (subscription.domainCaptureEnabled) {
+    const samlInitPath = (
+      await Modules.promises.hooks.fire(
+        'getGroupSSOInitPath',
+        subscription,
+        userEmail
+      )
+    )?.[0]
+    acceptInviteUrl = `${settings.siteUrl}${samlInitPath}`
+  } else {
+    acceptInviteUrl = `${settings.siteUrl}/subscription/invites/${currentInvite.token}/`
+  }
+
   const opts = {
   const opts = {
     to: userEmail,
     to: userEmail,
     admin: subscription.admin_id,
     admin: subscription.admin_id,
     inviter: currentInvite.inviterName,
     inviter: currentInvite.inviterName,
-    acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${currentInvite.token}/`,
+    acceptInviteUrl,
     reminder: true,
     reminder: true,
   }
   }
 
 

+ 50 - 20
services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs

@@ -172,6 +172,7 @@ async function createTeamInvitesForLegacyInvitedEmail(email) {
 }
 }
 
 
 async function _createInvite(subscription, email, inviter, auditLog) {
 async function _createInvite(subscription, email, inviter, auditLog) {
+  const { domainCaptureEnabled, managedUsersEnabled } = subscription
   const { possible, reason } = await _checkIfInviteIsPossible(
   const { possible, reason } = await _checkIfInviteIsPossible(
     subscription,
     subscription,
     email
     email
@@ -231,27 +232,42 @@ async function _createInvite(subscription, email, inviter, auditLog) {
     invite = invite.toObject()
     invite = invite.toObject()
     invite.sentAt = new Date()
     invite.sentAt = new Date()
   } else {
   } else {
-    invite = {
-      email,
-      inviterName,
-      token: crypto.randomBytes(32).toString('hex'),
-      sentAt: new Date(),
+    if (domainCaptureEnabled) {
+      invite = {
+        email,
+        inviterName,
+        sentAt: new Date(),
+        domainCapture: true,
+      }
+    } else {
+      invite = {
+        email,
+        inviterName,
+        token: crypto.randomBytes(32).toString('hex'),
+        sentAt: new Date(),
+      }
     }
     }
+
     subscription.teamInvites.push(invite)
     subscription.teamInvites.push(invite)
   }
   }
 
 
-  try {
-    await _sendNotificationToExistingUser(
-      subscription,
-      email,
-      invite,
-      subscription.managedUsersEnabled
-    )
-  } catch (err) {
-    logger.error(
-      { err },
-      'Failed to send notification to existing user when creating group invitation'
-    )
+  if (!domainCaptureEnabled) {
+    // no need to create notification when domain capture is enabled since
+    // dash will show one on page load for non-managed groups, and for managed groups
+    // dash is not loadable until user joins the group
+    try {
+      await _sendNotificationToExistingUser(
+        subscription,
+        email,
+        invite,
+        subscription.managedUsersEnabled
+      )
+    } catch (err) {
+      logger.error(
+        { err },
+        'Failed to send notification to existing user when creating group invitation'
+      )
+    }
   }
   }
 
 
   await subscription.save()
   await subscription.save()
@@ -273,7 +289,22 @@ async function _createInvite(subscription, email, inviter, auditLog) {
         'Error adding group audit log entry for group-invite-sent'
         'Error adding group audit log entry for group-invite-sent'
       )
       )
     }
     }
+  }
 
 
+  let acceptInviteUrl
+  if (domainCaptureEnabled) {
+    const samlInitPath = (
+      await Modules.promises.hooks.fire(
+        'getGroupSSOInitPath',
+        subscription,
+        email
+      )
+    )?.[0]
+    acceptInviteUrl = `${settings.siteUrl}${samlInitPath}`
+  } else {
+    acceptInviteUrl = `${settings.siteUrl}/subscription/invites/${invite.token}/`
+  }
+  if (managedUsersEnabled) {
     let admin = {}
     let admin = {}
     try {
     try {
       admin = await SubscriptionLocator.promises.getAdminEmailAndName(
       admin = await SubscriptionLocator.promises.getAdminEmailAndName(
@@ -289,7 +320,7 @@ async function _createInvite(subscription, email, inviter, auditLog) {
       to: email,
       to: email,
       admin,
       admin,
       inviter,
       inviter,
-      acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
+      acceptInviteUrl,
       appName: settings.appName,
       appName: settings.appName,
     }
     }
 
 
@@ -308,10 +339,9 @@ async function _createInvite(subscription, email, inviter, auditLog) {
     const opts = {
     const opts = {
       to: email,
       to: email,
       inviter,
       inviter,
-      acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
+      acceptInviteUrl,
       appName: settings.appName,
       appName: settings.appName,
     }
     }
-
     await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts)
     await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts)
   }
   }
 
 

+ 2 - 1
services/web/app/src/models/TeamInvite.mjs

@@ -6,8 +6,9 @@ export const TeamInviteSchema = new Schema(
   {
   {
     email: { type: String, required: true },
     email: { type: String, required: true },
     token: { type: String },
     token: { type: String },
-    inviterName: { type: String },
+    inviterName: { type: String, optional: true },
     sentAt: { type: Date },
     sentAt: { type: Date },
+    domainCapture: { type: Boolean, default: false, optional: true },
   },
   },
   { minimize: false }
   { minimize: false }
 )
 )

+ 58 - 1
services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs

@@ -1,5 +1,6 @@
 import { expect, vi } from 'vitest'
 import { expect, vi } from 'vitest'
 import sinon from 'sinon'
 import sinon from 'sinon'
+import MockResponse from '../helpers/MockResponse.js'
 
 
 const modulePath =
 const modulePath =
   '../../../../app/src/Features/Subscription/TeamInvitesController'
   '../../../../app/src/Features/Subscription/TeamInvitesController'
@@ -74,7 +75,9 @@ describe('TeamInvitesController', function () {
     }
     }
 
 
     ctx.RateLimiter = {
     ctx.RateLimiter = {
-      RateLimiter: class {},
+      RateLimiter: class {
+        consume = sinon.stub().resolves()
+      },
     }
     }
 
 
     vi.doMock(
     vi.doMock(
@@ -274,4 +277,58 @@ describe('TeamInvitesController', function () {
       })
       })
     })
     })
   })
   })
+
+  describe('resendInvite', function () {
+    const email = 'user@example.com'
+    const initPath = '/saml/ukamf/init?group_id=12345'
+    beforeEach(function (ctx) {
+      ctx.subscription = { teamInvites: [{ email }], populate: sinon.stub() }
+      ctx.req = {
+        entity: ctx.subscription,
+        body: {
+          email,
+        },
+      }
+      ctx.res = new MockResponse()
+      ctx.next = sinon.stub()
+    })
+
+    it('sends the invite email again', async function (ctx) {
+      await new Promise(resolve => {
+        const res = new MockResponse()
+        res.callback = () => {
+          res.statusCode.should.equal(200)
+          resolve()
+        }
+
+        ctx.Controller.resendInvite(ctx.req, res, ctx.next)
+      })
+    })
+
+    describe('when domain capture is enabled', function () {
+      beforeEach(function (ctx) {
+        ctx.req.entity.domainCaptureEnabled = true
+
+        ctx.Modules.promises.hooks.fire.resolves([initPath])
+      })
+
+      it('sends the invite again', async function (ctx) {
+        await new Promise(resolve => {
+          const res = new MockResponse()
+          res.callback = () => {
+            sinon.assert.calledWith(
+              ctx.Modules.promises.hooks.fire,
+              'getGroupSSOInitPath',
+              ctx.subscription,
+              email
+            )
+            res.statusCode.should.equal(200)
+            resolve()
+          }
+
+          ctx.Controller.resendInvite(ctx.req, res, ctx.next)
+        })
+      })
+    })
+  })
 })
 })

+ 85 - 1
services/web/test/unit/src/Subscription/TeamInvitesHandler.test.mjs

@@ -131,7 +131,7 @@ describe('TeamInvitesHandler', function () {
     }))
     }))
 
 
     vi.doMock('@overleaf/settings', () => ({
     vi.doMock('@overleaf/settings', () => ({
-      default: { siteUrl: 'http://example.com' },
+      default: { siteUrl: 'http://example.com', appName: 'Overleaf' },
     }))
     }))
 
 
     vi.doMock('../../../../app/src/models/TeamInvite', () => ({
     vi.doMock('../../../../app/src/models/TeamInvite', () => ({
@@ -414,6 +414,90 @@ describe('TeamInvitesHandler', function () {
         'addGroupAuditLogEntry'
         'addGroupAuditLogEntry'
       )
       )
     })
     })
+
+    describe('when domain capture is enabled', function () {
+      it('creates a domain capture invite', async function (ctx) {
+        const initPath = '/saml/ukamf/init?group_id=12345'
+        ctx.Modules.promises.hooks.fire.resolves([initPath])
+        ctx.UserGetter.promises.getUser.resolves(ctx.manager)
+
+        ctx.subscription.domainCaptureEnabled = true
+        const invite = await ctx.TeamInvitesHandler.promises.createInvite(
+          ctx.manager._id,
+          ctx.subscription,
+          'user@example.com',
+          { domainCapture: true }
+        )
+        expect(invite.token).to.be.undefined
+        expect(invite.domainCapture).to.be.true
+        expect(invite.email).to.eq('user@example.com')
+
+        sinon.assert.calledWith(
+          ctx.Modules.promises.hooks.fire,
+          'getGroupSSOInitPath',
+          ctx.subscription,
+          invite.email
+        )
+
+        ctx.EmailHandler.promises.sendEmail
+          .calledWith(
+            'verifyEmailToJoinTeam',
+            sinon.match({
+              to: 'user@example.com',
+              inviter: ctx.manager,
+              acceptInviteUrl: `http://example.com${initPath}`,
+              appName: 'Overleaf',
+            })
+          )
+          .should.equal(true)
+      })
+
+      describe('when managed users is also enabled', function () {
+        it('creates a domain capture invite', async function (ctx) {
+          ctx.SubscriptionLocator.promises.getAdminEmailAndName = sinon
+            .stub()
+            .resolves(ctx.manager)
+
+          ctx.UserGetter.promises.getUserByAnyEmail
+            .withArgs('user@example.com')
+            .resolves(ctx.user)
+          const initPath = '/saml/ukamf/init?group_id=12345'
+          ctx.Modules.promises.hooks.fire.resolves([initPath])
+          ctx.UserGetter.promises.getUser.resolves(ctx.manager)
+          ctx.subscription.managedUsersEnabled = true
+          ctx.subscription.domainCaptureEnabled = true
+          const invite = await ctx.TeamInvitesHandler.promises.createInvite(
+            ctx.manager._id,
+            ctx.subscription,
+            'user@example.com',
+            { domainCapture: true }
+          )
+          expect(invite.token).to.be.undefined
+          expect(invite.domainCapture).to.be.true
+          expect(invite.email).to.eq('user@example.com')
+
+          sinon.assert.calledWith(
+            ctx.Modules.promises.hooks.fire,
+            'getGroupSSOInitPath',
+            ctx.subscription,
+            invite.email
+          )
+
+          ctx.EmailHandler.promises.sendEmail
+            .calledWith(
+              'inviteNewUserToJoinManagedUsers',
+              sinon.match({
+                to: 'user@example.com',
+                inviter: ctx.manager,
+                acceptInviteUrl: `http://example.com${initPath}`,
+                appName: 'Overleaf',
+                admin: ctx.manager,
+              })
+            )
+            .should.equal(true)
+        })
+      })
+    })
   })
   })
 
 
   describe('importInvite', function () {
   describe('importInvite', function () {