From 2e3ddb09655ff88d153226e2c71fdf88b91af01a Mon Sep 17 00:00:00 2001 From: Jessica Lawshe <5312836+lawshe@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:54:47 -0600 Subject: [PATCH] Merge pull request #29881 from overleaf/jel-domain-capture-email-invite-cta [web] Update CTA for domain capture group invites GitOrigin-RevId: addaa81c443da63124b7841f087e34145fe3bfe6 --- .../Subscription/TeamInvitesController.mjs | 16 +++- .../Subscription/TeamInvitesHandler.mjs | 70 ++++++++++----- services/web/app/src/models/TeamInvite.mjs | 3 +- .../TeamInvitesController.test.mjs | 59 ++++++++++++- .../Subscription/TeamInvitesHandler.test.mjs | 86 ++++++++++++++++++- 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs index c4dee9970a..6fe10d5b5e 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.mjs @@ -269,11 +269,25 @@ async function resendInvite(req, res, next) { 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 = { to: userEmail, admin: subscription.admin_id, inviter: currentInvite.inviterName, - acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${currentInvite.token}/`, + acceptInviteUrl, reminder: true, } diff --git a/services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs b/services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs index bcc85abeff..480aece9c2 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs +++ b/services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs @@ -172,6 +172,7 @@ async function createTeamInvitesForLegacyInvitedEmail(email) { } async function _createInvite(subscription, email, inviter, auditLog) { + const { domainCaptureEnabled, managedUsersEnabled } = subscription const { possible, reason } = await _checkIfInviteIsPossible( subscription, email @@ -231,27 +232,42 @@ async function _createInvite(subscription, email, inviter, auditLog) { invite = invite.toObject() invite.sentAt = new Date() } 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) } - 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() @@ -273,7 +289,22 @@ async function _createInvite(subscription, email, inviter, auditLog) { '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 = {} try { admin = await SubscriptionLocator.promises.getAdminEmailAndName( @@ -289,7 +320,7 @@ async function _createInvite(subscription, email, inviter, auditLog) { to: email, admin, inviter, - acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`, + acceptInviteUrl, appName: settings.appName, } @@ -308,10 +339,9 @@ async function _createInvite(subscription, email, inviter, auditLog) { const opts = { to: email, inviter, - acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`, + acceptInviteUrl, appName: settings.appName, } - await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts) } diff --git a/services/web/app/src/models/TeamInvite.mjs b/services/web/app/src/models/TeamInvite.mjs index e121f0606e..1097c159ab 100644 --- a/services/web/app/src/models/TeamInvite.mjs +++ b/services/web/app/src/models/TeamInvite.mjs @@ -6,8 +6,9 @@ export const TeamInviteSchema = new Schema( { email: { type: String, required: true }, token: { type: String }, - inviterName: { type: String }, + inviterName: { type: String, optional: true }, sentAt: { type: Date }, + domainCapture: { type: Boolean, default: false, optional: true }, }, { minimize: false } ) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs index 0d777a5b44..fe52e388c1 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesController.test.mjs @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest' import sinon from 'sinon' +import MockResponse from '../helpers/MockResponse.js' const modulePath = '../../../../app/src/Features/Subscription/TeamInvitesController' @@ -74,7 +75,9 @@ describe('TeamInvitesController', function () { } ctx.RateLimiter = { - RateLimiter: class {}, + RateLimiter: class { + consume = sinon.stub().resolves() + }, } 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) + }) + }) + }) + }) }) diff --git a/services/web/test/unit/src/Subscription/TeamInvitesHandler.test.mjs b/services/web/test/unit/src/Subscription/TeamInvitesHandler.test.mjs index 0f7cadb9f2..54d18cfc28 100644 --- a/services/web/test/unit/src/Subscription/TeamInvitesHandler.test.mjs +++ b/services/web/test/unit/src/Subscription/TeamInvitesHandler.test.mjs @@ -131,7 +131,7 @@ describe('TeamInvitesHandler', function () { })) vi.doMock('@overleaf/settings', () => ({ - default: { siteUrl: 'http://example.com' }, + default: { siteUrl: 'http://example.com', appName: 'Overleaf' }, })) vi.doMock('../../../../app/src/models/TeamInvite', () => ({ @@ -414,6 +414,90 @@ describe('TeamInvitesHandler', function () { '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 () {