Files
overleaf-cep/services/web/app/src/Features/Subscription/TeamInvitesHandler.mjs
T
Jessica Lawshe 59dc237e53 Merge pull request #30835 from overleaf/ii-domain-capture-invite-token
[web] Create invite token when domain capture is disabled

GitOrigin-RevId: 193b5b8567c3b593c98f432568b47cc4e9e17339
2026-01-23 09:06:24 +00:00

468 lines
12 KiB
JavaScript

import logger from '@overleaf/logger'
import crypto from 'node:crypto'
import settings from '@overleaf/settings'
import Modules from '../../infrastructure/Modules.mjs'
import mongodb from 'mongodb-legacy'
import { Subscription } from '../../models/Subscription.mjs'
import { SSOConfig } from '../../models/SSOConfig.mjs'
import UserGetter from '../User/UserGetter.mjs'
import SubscriptionLocator from './SubscriptionLocator.mjs'
import SubscriptionUpdater from './SubscriptionUpdater.mjs'
import LimitationsManager from './LimitationsManager.mjs'
import EmailHandler from '../Email/EmailHandler.mjs'
import EmailHelper from '../Helpers/EmailHelper.mjs'
import Errors from '../Errors/Errors.js'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
const { ObjectId } = mongodb
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 }
}
async function createInvite(teamManagerId, subscription, email, auditLog) {
email = EmailHelper.parseEmail(email)
if (!email) {
throw new Error('invalid email')
}
const teamManager = await UserGetter.promises.getUser(teamManagerId)
await _removeLegacyInvite(subscription.id, email)
return _createInvite(subscription, email, teamManager, auditLog)
}
async function importInvite(subscription, inviterName, email, token, sentAt) {
const { possible, reason } = await _checkIfInviteIsPossible(
subscription,
email
)
if (!possible) {
throw reason
}
subscription.teamInvites.push({
email,
inviterName,
token,
sentAt,
})
return subscription.save()
}
async function _deleteUserSubscription(subscription, userId, ipAddress) {
// Delete released user subscription to make it on a free plan
logger.debug(
{
subscriptionId: subscription._id,
},
'deleting user subscription'
)
const deleterData = {
id: userId,
ip: ipAddress,
}
await SubscriptionUpdater.promises.deleteSubscription(
subscription,
deleterData
)
try {
await Modules.promises.hooks.fire('terminateSubscription', subscription)
} catch (err) {
logger.error(
{ err, subscriptionId: subscription._id },
'terminating subscription failed'
)
}
}
async function removeTeamInviteAndNotification(subscriptionId, userId, email) {
await _removeInviteFromTeam(subscriptionId, email)
await NotificationsBuilder.promises
.groupInvitation(userId, subscriptionId, false)
.read()
}
async function acceptInvite(token, userId, ipAddress) {
const { invite, subscription } = await getInvite(token)
if (!invite) {
throw new Errors.NotFoundError('invite not found')
}
const auditLog = { initiatorId: userId, ipAddress }
await SubscriptionUpdater.promises.addUserToGroup(
subscription._id,
userId,
auditLog
)
if (subscription.managedUsersEnabled) {
// check if user has a personal subscription
const userSubscription =
await SubscriptionLocator.promises.getUsersSubscription(userId)
if (userSubscription) {
// if user has a personal subscription and joins a managed group, delete their personal subscription
// but make sure that it's not the same subscription as the group one.
if (!userSubscription._id.equals(subscription._id)) {
await _deleteUserSubscription(userSubscription, userId, ipAddress)
}
}
await Modules.promises.hooks.fire(
'enrollInManagedSubscription',
userId,
subscription,
auditLog
)
}
if (subscription.ssoConfig) {
const ssoConfig = await SSOConfig.findById(
subscription.ssoConfig._id || subscription.ssoConfig
)
if (ssoConfig?.enabled) {
await Modules.promises.hooks.fire(
'scheduleGroupSSOReminder',
userId,
subscription._id
)
}
}
await removeTeamInviteAndNotification(subscription._id, userId, invite.email)
return subscription
}
async function revokeInvite(teamManagerId, subscription, email) {
email = EmailHelper.parseEmail(email)
if (!email) {
throw new Error('invalid email')
}
await _removeInviteFromTeam(subscription.id, email)
// Remove group invitation dashboard notification if invitation is revoked before
// the invited user accepted the group invitation
const user = await UserGetter.promises.getUserByAnyEmail(email)
if (user) {
await NotificationsBuilder.promises
.groupInvitation(user._id, subscription._id, false)
.read()
}
}
// 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.
async function createTeamInvitesForLegacyInvitedEmail(email) {
const teams =
await SubscriptionLocator.promises.getGroupsWithEmailInvite(email)
return Promise.all(
teams.map(team => createInvite(team.admin_id, team, email))
)
}
async function _createInvite(subscription, email, inviter, auditLog) {
const { domainCaptureEnabled, managedUsersEnabled } = subscription
const { possible, reason } = await _checkIfInviteIsPossible(
subscription,
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) {
const auditLog = { initiatorId: inviter._id }
await SubscriptionUpdater.promises.addUserToGroup(
subscription._id,
inviter._id,
auditLog
)
// legacy: remove any invite that might have been created in the past
await _removeInviteFromTeam(subscription._id, email)
try {
if (subscription.ssoConfig) {
const ssoConfig = await SSOConfig.findById(
subscription.ssoConfig._id || subscription.ssoConfig
)
if (ssoConfig?.enabled) {
await Modules.promises.hooks.fire(
'sendGroupSSOReminder',
inviter._id,
subscription._id
)
}
}
} catch (error) {
logger.error(
{ err: error, userId: inviter._id, subscriptionId: subscription._id },
'Failed to schedule Group SSO invite for group admin'
)
}
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.sentAt = new Date()
if (!invite.token && !domainCaptureEnabled) {
invite.token = crypto.randomBytes(32).toString('hex')
invite.domainCapture = false
}
} else {
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)
}
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()
if (subscription.managedUsersEnabled) {
const auditLogData = {
initiatorId: auditLog?.initiatorId,
ipAddress: auditLog?.ipAddress,
groupId: subscription._id,
operation: 'group-invite-sent',
info: { invitedEmail: email },
}
try {
await Modules.promises.hooks.fire('addGroupAuditLogEntry', auditLogData)
} catch (error) {
logger.error(
{ error, 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(
subscription._id
)
} catch (err) {
logger.error({ err }, 'error getting subscription admin email and name')
}
const user = await UserGetter.promises.getUserByAnyEmail(email)
const opts = {
to: email,
admin,
inviter,
acceptInviteUrl,
appName: settings.appName,
}
if (user) {
await EmailHandler.promises.sendEmail(
'verifyEmailToJoinManagedUsers',
opts
)
} else {
await EmailHandler.promises.sendEmail(
'inviteNewUserToJoinManagedUsers',
opts
)
}
} else {
const opts = {
to: email,
inviter,
acceptInviteUrl,
appName: settings.appName,
}
await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts)
}
Object.assign(invite, { invite: true })
return invite
}
async function _removeInviteFromTeam(subscriptionId, email, callback) {
const searchConditions = { _id: new ObjectId(subscriptionId.toString()) }
const removeInvite = { $pull: { teamInvites: { email } } }
await Subscription.updateOne(searchConditions, removeInvite)
await _removeLegacyInvite(subscriptionId, email)
}
async function _sendNotificationToExistingUser(
subscription,
email,
invite,
managedUsersEnabled
) {
const user = await UserGetter.promises.getUserByMainEmail(email)
if (!user) {
return
}
await NotificationsBuilder.promises
.groupInvitation(
user._id.toString(),
subscription._id.toString(),
managedUsersEnabled
)
.create(invite)
}
async function _removeLegacyInvite(subscriptionId, email) {
await Subscription.updateOne(
{
_id: new ObjectId(subscriptionId.toString()),
},
{
$pull: {
invited_emails: email,
},
}
)
}
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 { possible: false, reason: { wrongPlan: true } }
}
if (LimitationsManager.teamHasReachedMemberLimit(subscription)) {
logger.debug(
{ subscriptionId: subscription.id },
'team has reached member limit'
)
return { possible: false, reason: { limitReached: 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()
)
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) {
let inviterName
if (inviter.first_name && inviter.last_name) {
inviterName = `${inviter.first_name} ${inviter.last_name} (${inviter.email})`
} else {
inviterName = inviter.email
}
return inviterName
}
export default {
getInvite: callbackifyMultiResult(getInvite, ['invite', 'subscription']),
createInvite: callbackify(createInvite),
importInvite: callbackify(importInvite),
acceptInvite: callbackify(acceptInvite),
revokeInvite: callbackify(revokeInvite),
createTeamInvitesForLegacyInvitedEmail: callbackify(
createTeamInvitesForLegacyInvitedEmail
),
promises: {
getInvite,
createInvite,
importInvite,
acceptInvite,
removeTeamInviteAndNotification,
revokeInvite,
createTeamInvitesForLegacyInvitedEmail,
},
}