TeamInvitesHandler.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import logger from '@overleaf/logger'
  2. import crypto from 'node:crypto'
  3. import settings from '@overleaf/settings'
  4. import Modules from '../../infrastructure/Modules.js'
  5. import mongodb from 'mongodb-legacy'
  6. import { Subscription } from '../../models/Subscription.js'
  7. import { SSOConfig } from '../../models/SSOConfig.js'
  8. import UserGetter from '../User/UserGetter.mjs'
  9. import SubscriptionLocator from './SubscriptionLocator.mjs'
  10. import SubscriptionUpdater from './SubscriptionUpdater.mjs'
  11. import LimitationsManager from './LimitationsManager.mjs'
  12. import EmailHandler from '../Email/EmailHandler.mjs'
  13. import EmailHelper from '../Helpers/EmailHelper.js'
  14. import Errors from '../Errors/Errors.js'
  15. import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
  16. import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
  17. import RecurlyClient from './RecurlyClient.mjs'
  18. const { ObjectId } = mongodb
  19. async function getInvite(token) {
  20. const subscription = await Subscription.findOne({
  21. 'teamInvites.token': token,
  22. })
  23. if (!subscription) {
  24. throw new Errors.NotFoundError('team not found')
  25. }
  26. const invite = subscription.teamInvites.find(i => i.token === token)
  27. return { invite, subscription }
  28. }
  29. async function createInvite(teamManagerId, subscription, email) {
  30. email = EmailHelper.parseEmail(email)
  31. if (!email) {
  32. throw new Error('invalid email')
  33. }
  34. const teamManager = await UserGetter.promises.getUser(teamManagerId)
  35. await _removeLegacyInvite(subscription.id, email)
  36. return _createInvite(subscription, email, teamManager)
  37. }
  38. async function importInvite(subscription, inviterName, email, token, sentAt) {
  39. const { possible, reason } = await _checkIfInviteIsPossible(
  40. subscription,
  41. email
  42. )
  43. if (!possible) {
  44. throw reason
  45. }
  46. subscription.teamInvites.push({
  47. email,
  48. inviterName,
  49. token,
  50. sentAt,
  51. })
  52. return subscription.save()
  53. }
  54. async function _deleteUserSubscription(subscription, userId, ipAddress) {
  55. // Delete released user subscription to make it on a free plan
  56. logger.debug(
  57. {
  58. subscriptionId: subscription._id,
  59. },
  60. 'deleting user subscription'
  61. )
  62. const deleterData = {
  63. id: userId,
  64. ip: ipAddress,
  65. }
  66. await SubscriptionUpdater.promises.deleteSubscription(
  67. subscription,
  68. deleterData
  69. )
  70. // Terminate the subscription in Recurly
  71. if (subscription.recurlySubscription_id) {
  72. try {
  73. await RecurlyClient.promises.terminateSubscriptionByUuid(
  74. subscription.recurlySubscription_id
  75. )
  76. } catch (err) {
  77. logger.error(
  78. { err, subscriptionId: subscription._id },
  79. 'terminating subscription failed'
  80. )
  81. }
  82. }
  83. }
  84. async function acceptInvite(token, userId, ipAddress) {
  85. const { invite, subscription } = await getInvite(token)
  86. if (!invite) {
  87. throw new Errors.NotFoundError('invite not found')
  88. }
  89. const auditLog = { initiatorId: userId, ipAddress }
  90. await SubscriptionUpdater.promises.addUserToGroup(
  91. subscription._id,
  92. userId,
  93. auditLog
  94. )
  95. if (subscription.managedUsersEnabled) {
  96. // check if user has a personal subscription
  97. const userSubscription =
  98. await SubscriptionLocator.promises.getUsersSubscription(userId)
  99. if (userSubscription) {
  100. // if user has a personal subscription and joins a managed group, delete their personal subscription
  101. // but make sure that it's not the same subscription as the group one.
  102. if (!userSubscription._id.equals(subscription._id)) {
  103. await _deleteUserSubscription(userSubscription, userId, ipAddress)
  104. }
  105. }
  106. await Modules.promises.hooks.fire(
  107. 'enrollInManagedSubscription',
  108. userId,
  109. subscription,
  110. auditLog
  111. )
  112. }
  113. if (subscription.ssoConfig) {
  114. const ssoConfig = await SSOConfig.findById(
  115. subscription.ssoConfig._id || subscription.ssoConfig
  116. )
  117. if (ssoConfig?.enabled) {
  118. await Modules.promises.hooks.fire(
  119. 'scheduleGroupSSOReminder',
  120. userId,
  121. subscription._id
  122. )
  123. }
  124. }
  125. await _removeInviteFromTeam(subscription.id, invite.email)
  126. await NotificationsBuilder.promises
  127. .groupInvitation(userId, subscription._id, false)
  128. .read()
  129. return subscription
  130. }
  131. async function revokeInvite(teamManagerId, subscription, email) {
  132. email = EmailHelper.parseEmail(email)
  133. if (!email) {
  134. throw new Error('invalid email')
  135. }
  136. await _removeInviteFromTeam(subscription.id, email)
  137. // Remove group invitation dashboard notification if invitation is revoked before
  138. // the invited user accepted the group invitation
  139. const user = await UserGetter.promises.getUserByAnyEmail(email)
  140. if (user) {
  141. await NotificationsBuilder.promises
  142. .groupInvitation(user._id, subscription._id, false)
  143. .read()
  144. }
  145. }
  146. // Legacy method to allow a user to receive a confirmation email if their
  147. // email is in Subscription.invited_emails when they join. We'll remove this
  148. // after a short while.
  149. async function createTeamInvitesForLegacyInvitedEmail(email) {
  150. const teams =
  151. await SubscriptionLocator.promises.getGroupsWithEmailInvite(email)
  152. return Promise.all(
  153. teams.map(team => createInvite(team.admin_id, team, email))
  154. )
  155. }
  156. async function _createInvite(subscription, email, inviter) {
  157. const { possible, reason } = await _checkIfInviteIsPossible(
  158. subscription,
  159. email
  160. )
  161. if (!possible) {
  162. throw reason
  163. }
  164. // don't send invites when inviting self; add user directly to the group
  165. const isInvitingSelf = inviter.emails.some(
  166. emailData => emailData.email === email
  167. )
  168. if (isInvitingSelf) {
  169. const auditLog = { initiatorId: inviter._id }
  170. await SubscriptionUpdater.promises.addUserToGroup(
  171. subscription._id,
  172. inviter._id,
  173. auditLog
  174. )
  175. // legacy: remove any invite that might have been created in the past
  176. await _removeInviteFromTeam(subscription._id, email)
  177. try {
  178. if (subscription.ssoConfig) {
  179. const ssoConfig = await SSOConfig.findById(
  180. subscription.ssoConfig._id || subscription.ssoConfig
  181. )
  182. if (ssoConfig?.enabled) {
  183. await Modules.promises.hooks.fire(
  184. 'sendGroupSSOReminder',
  185. inviter._id,
  186. subscription._id
  187. )
  188. }
  189. }
  190. } catch (error) {
  191. logger.error(
  192. { err: error, userId: inviter._id, subscriptionId: subscription._id },
  193. 'Failed to schedule Group SSO invite for group admin'
  194. )
  195. }
  196. return {
  197. email: inviter.email,
  198. first_name: inviter.first_name,
  199. last_name: inviter.last_name,
  200. invite: false,
  201. }
  202. }
  203. const inviterName = _getInviterName(inviter)
  204. let invite = subscription.teamInvites.find(invite => invite.email === email)
  205. if (invite) {
  206. invite = invite.toObject()
  207. invite.sentAt = new Date()
  208. } else {
  209. invite = {
  210. email,
  211. inviterName,
  212. token: crypto.randomBytes(32).toString('hex'),
  213. sentAt: new Date(),
  214. }
  215. subscription.teamInvites.push(invite)
  216. }
  217. try {
  218. await _sendNotificationToExistingUser(
  219. subscription,
  220. email,
  221. invite,
  222. subscription.managedUsersEnabled
  223. )
  224. } catch (err) {
  225. logger.error(
  226. { err },
  227. 'Failed to send notification to existing user when creating group invitation'
  228. )
  229. }
  230. await subscription.save()
  231. if (subscription.managedUsersEnabled) {
  232. let admin = {}
  233. try {
  234. admin = await SubscriptionLocator.promises.getAdminEmailAndName(
  235. subscription._id
  236. )
  237. } catch (err) {
  238. logger.error({ err }, 'error getting subscription admin email and name')
  239. }
  240. const user = await UserGetter.promises.getUserByAnyEmail(email)
  241. const opts = {
  242. to: email,
  243. admin,
  244. inviter,
  245. acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
  246. appName: settings.appName,
  247. }
  248. if (user) {
  249. await EmailHandler.promises.sendEmail(
  250. 'verifyEmailToJoinManagedUsers',
  251. opts
  252. )
  253. } else {
  254. await EmailHandler.promises.sendEmail(
  255. 'inviteNewUserToJoinManagedUsers',
  256. opts
  257. )
  258. }
  259. } else {
  260. const opts = {
  261. to: email,
  262. inviter,
  263. acceptInviteUrl: `${settings.siteUrl}/subscription/invites/${invite.token}/`,
  264. appName: settings.appName,
  265. }
  266. await EmailHandler.promises.sendEmail('verifyEmailToJoinTeam', opts)
  267. }
  268. Object.assign(invite, { invite: true })
  269. return invite
  270. }
  271. async function _removeInviteFromTeam(subscriptionId, email, callback) {
  272. const searchConditions = { _id: new ObjectId(subscriptionId.toString()) }
  273. const removeInvite = { $pull: { teamInvites: { email } } }
  274. await Subscription.updateOne(searchConditions, removeInvite)
  275. await _removeLegacyInvite(subscriptionId, email)
  276. }
  277. async function _sendNotificationToExistingUser(
  278. subscription,
  279. email,
  280. invite,
  281. managedUsersEnabled
  282. ) {
  283. const user = await UserGetter.promises.getUserByMainEmail(email)
  284. if (!user) {
  285. return
  286. }
  287. await NotificationsBuilder.promises
  288. .groupInvitation(
  289. user._id.toString(),
  290. subscription._id.toString(),
  291. managedUsersEnabled
  292. )
  293. .create(invite)
  294. }
  295. async function _removeLegacyInvite(subscriptionId, email) {
  296. await Subscription.updateOne(
  297. {
  298. _id: new ObjectId(subscriptionId.toString()),
  299. },
  300. {
  301. $pull: {
  302. invited_emails: email,
  303. },
  304. }
  305. )
  306. }
  307. async function _checkIfInviteIsPossible(subscription, email) {
  308. if (!subscription.groupPlan) {
  309. logger.debug(
  310. { subscriptionId: subscription.id },
  311. 'can not add members to a subscription that is not in a group plan'
  312. )
  313. return { possible: false, reason: { wrongPlan: true } }
  314. }
  315. if (LimitationsManager.teamHasReachedMemberLimit(subscription)) {
  316. logger.debug(
  317. { subscriptionId: subscription.id },
  318. 'team has reached member limit'
  319. )
  320. return { possible: false, reason: { limitReached: true } }
  321. }
  322. const existingUser = await UserGetter.promises.getUserByAnyEmail(email)
  323. if (!existingUser) {
  324. return { possible: true }
  325. }
  326. const existingMember = subscription.member_ids.find(
  327. memberId => memberId.toString() === existingUser._id.toString()
  328. )
  329. if (existingMember) {
  330. logger.debug(
  331. { subscriptionId: subscription.id, email },
  332. 'user already in team'
  333. )
  334. return { possible: false, reason: { alreadyInTeam: true } }
  335. } else {
  336. return { possible: true }
  337. }
  338. }
  339. function _getInviterName(inviter) {
  340. let inviterName
  341. if (inviter.first_name && inviter.last_name) {
  342. inviterName = `${inviter.first_name} ${inviter.last_name} (${inviter.email})`
  343. } else {
  344. inviterName = inviter.email
  345. }
  346. return inviterName
  347. }
  348. export default {
  349. getInvite: callbackifyMultiResult(getInvite, ['invite', 'subscription']),
  350. createInvite: callbackify(createInvite),
  351. importInvite: callbackify(importInvite),
  352. acceptInvite: callbackify(acceptInvite),
  353. revokeInvite: callbackify(revokeInvite),
  354. createTeamInvitesForLegacyInvitedEmail: callbackify(
  355. createTeamInvitesForLegacyInvitedEmail
  356. ),
  357. promises: {
  358. getInvite,
  359. createInvite,
  360. importInvite,
  361. acceptInvite,
  362. revokeInvite,
  363. createTeamInvitesForLegacyInvitedEmail,
  364. },
  365. }