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

[web] Update CTA for domain capture group invites

GitOrigin-RevId: addaa81c443da63124b7841f087e34145fe3bfe6
This commit is contained in:
Jessica Lawshe
2025-12-04 09:54:47 -06:00
committed by Copybot
parent 8971d05384
commit 2e3ddb0965
5 changed files with 210 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

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