Explorar el Código

Merge pull request #32111 from overleaf/as-new-link-sharing-backend

[web] Support link sharing using ProjectInvite

GitOrigin-RevId: 45d988dd6451f962f2709bb1e72a715570816d9c
MoxAmber hace 2 meses
padre
commit
28210bcaeb
Se han modificado 19 ficheros con 1323 adiciones y 59 borrados
  1. 10 0
      server-ce/config/settings.js
  2. 26 1
      services/web/app/src/Features/Authorization/PrivilegeLevels.mjs
  3. 6 1
      services/web/app/src/Features/Authorization/types.d.ts
  4. 291 17
      services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs
  5. 20 1
      services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs
  6. 66 12
      services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs
  7. 33 0
      services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs
  8. 54 0
      services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs
  9. 2 0
      services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs
  10. 5 12
      services/web/app/src/Features/TokenAccess/TokenAccessController.mjs
  11. 3 0
      services/web/app/src/models/ProjectInvite.mjs
  12. 60 5
      services/web/frontend/js/features/share-project/invite-root.tsx
  13. 7 0
      services/web/test/acceptance/config/settings.test.defaults.js
  14. 291 0
      services/web/test/acceptance/src/ProjectInviteTests.mjs
  15. 127 0
      services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs
  16. 1 1
      services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs
  17. 193 3
      services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs
  18. 41 1
      services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs
  19. 87 5
      services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs

+ 10 - 0
server-ce/config/settings.js

@@ -305,6 +305,16 @@ const settings = {
   },
 }
 
+// This secret is used for encrypting sharing link tokens in the database
+if (process.env.OVERLEAF_INVITE_TOKEN_SECRET) {
+  module.exports.projectInviteEncryptorOptions = {
+    cipherLabel: '2026.3-v3',
+    cipherPasswords: {
+      '2026.3-v3': process.env.OVERLEAF_INVITE_TOKEN_SECRET,
+    },
+  }
+}
+
 // # OPTIONAL CONFIGURABLE SETTINGS
 
 if (process.env.OVERLEAF_LEFT_FOOTER != null) {

+ 26 - 1
services/web/app/src/Features/Authorization/PrivilegeLevels.mjs

@@ -1,6 +1,8 @@
 // @ts-check
 
-/** @type {import('./types').PrivilegeLevelsType} */
+import Errors from '../Errors/Errors.js'
+
+/** @type {typeof import('./types').PrivilegeLevelsType} */
 const PrivilegeLevels = {
   NONE: false,
   READ_ONLY: 'readOnly',
@@ -9,4 +11,27 @@ const PrivilegeLevels = {
   OWNER: 'owner',
 }
 
+/** @type {import('./types').PrivilegeLevel[]} */
+export const OrderedPrivilegeLevels = [
+  PrivilegeLevels.NONE,
+  PrivilegeLevels.READ_ONLY,
+  PrivilegeLevels.REVIEW,
+  PrivilegeLevels.READ_AND_WRITE,
+  PrivilegeLevels.OWNER,
+]
+
+/** @type {typeof import('./types').isPrivilegeUpgrade} */
+export function isPrivilegeUpgrade(currentLevel, newLevel) {
+  if (
+    !OrderedPrivilegeLevels.includes(currentLevel) ||
+    !OrderedPrivilegeLevels.includes(newLevel)
+  ) {
+    throw new Errors.InvalidError('Invalid privilege level specified')
+  }
+  return (
+    OrderedPrivilegeLevels.indexOf(newLevel) >
+    OrderedPrivilegeLevels.indexOf(currentLevel)
+  )
+}
+
 export default PrivilegeLevels

+ 6 - 1
services/web/app/src/Features/Authorization/types.d.ts

@@ -12,7 +12,7 @@ export const PrivilegeLevelsType = {
   NONE: false,
   READ_ONLY: 'readOnly',
   READ_AND_WRITE: 'readAndWrite',
-  REVIEW: 'reviewer',
+  REVIEW: 'review',
   OWNER: 'owner',
 } as const
 
@@ -26,3 +26,8 @@ export const PublicAccessLevelsType = {
 } as const
 
 export type PublicAccessLevel = ValueOf<typeof PublicAccessLevelsType>
+
+export function isPrivilegeUpgrade(
+  currentLevel: PrivilegeLevel,
+  newLevel: PrivilegeLevel
+): boolean

+ 291 - 17
services/web/app/src/Features/Collaborators/CollaboratorsInviteController.mjs

@@ -17,8 +17,11 @@ import { expressify } from '@overleaf/promise-utils'
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import Errors from '../Errors/Errors.js'
 import AuthenticationController from '../Authentication/AuthenticationController.mjs'
-import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
+import PrivilegeLevels, {
+  isPrivilegeUpgrade,
+} from '../Authorization/PrivilegeLevels.mjs'
 import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
+import SubscriptionGroupHandler from '../Subscription/SubscriptionGroupHandler.mjs'
 
 // This rate limiter allows a different number of requests depending on the
 // number of callaborators a user is allowed. This is implemented by providing
@@ -260,6 +263,16 @@ async function generateNewInvite(req, res) {
   }
 }
 
+const _renderInvalidPage = function (res, projectId, sharingUpdates) {
+  res.status(404)
+  logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
+  if (sharingUpdates === 'enabled') {
+    res.render('project/invite/not-valid', { title: 'Invalid Invite' })
+  } else {
+    res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' })
+  }
+}
+
 async function viewInvite(req, res) {
   const projectId = req.params.Project_id
   const { token } = req.params
@@ -267,17 +280,6 @@ async function viewInvite(req, res) {
   const { variant: sharingUpdates } =
     await SplitTestHandler.promises.getAssignment(req, res, 'sharing-updates')
 
-  const _renderInvalidPage = function () {
-    res.status(404)
-    logger.debug({ projectId }, 'invite not valid, rendering not-valid page')
-
-    if (sharingUpdates === 'enabled') {
-      res.render('project/invite/not-valid', { title: 'Invalid Invite' })
-    } else {
-      res.render('project/invite/not-valid-legacy', { title: 'Invalid Invite' })
-    }
-  }
-
   // check if the user is already a member of the project
   const currentUser = SessionManager.getSessionUser(req.session)
   if (currentUser) {
@@ -304,7 +306,7 @@ async function viewInvite(req, res) {
   // check if invite is gone, or otherwise non-existent
   if (invite == null) {
     logger.debug({ projectId }, 'no invite found for this token')
-    return _renderInvalidPage()
+    return _renderInvalidPage(res, projectId, sharingUpdates)
   }
 
   // check the user who sent the invite exists
@@ -314,7 +316,7 @@ async function viewInvite(req, res) {
   )
   if (owner == null) {
     logger.debug({ projectId }, 'no project owner found')
-    return _renderInvalidPage()
+    return _renderInvalidPage(res, projectId, sharingUpdates)
   }
 
   // fetch the project name
@@ -323,7 +325,7 @@ async function viewInvite(req, res) {
   })
   if (project == null) {
     logger.debug({ projectId }, 'no project found')
-    return _renderInvalidPage()
+    return _renderInvalidPage(res, projectId, sharingUpdates)
   }
 
   if (!currentUser) {
@@ -357,14 +359,44 @@ async function viewInvite(req, res) {
   }
 }
 
+async function viewSharingLink(req, res) {
+  const projectId = req.params.Project_id
+
+  // ensure the project exists
+  const project = await ProjectGetter.promises.getProject(projectId, {
+    name: 1,
+  })
+  if (project == null) {
+    logger.debug({ projectId }, 'no project found')
+    return _renderInvalidPage(res, projectId, 'enabled')
+  }
+
+  const currentUser = SessionManager.getSessionUser(req.session)
+  if (!currentUser) {
+    AuthenticationController.setRedirectInSession(req)
+    return res.redirect('/register')
+  }
+
+  // cleanup if set for register page
+  delete req.session.sharedProjectData
+
+  res.render('project/invite/show', {
+    projectId,
+    title: 'Project Invite',
+  })
+}
+
 async function acceptInvite(req, res) {
-  const { Project_id: projectId, token } = req.params
+  const { Project_id: projectId, token: urlToken } = req.params
+  const { token: bodyToken } = req.body
   const currentUser = SessionManager.getSessionUser(req.session)
   logger.debug(
     { projectId, userId: currentUser._id },
     'got request to accept invite'
   )
 
+  const token = urlToken || bodyToken
+
   const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
     projectId,
     token
@@ -374,6 +406,54 @@ async function acceptInvite(req, res) {
     throw new Errors.NotFoundError('no matching invite found')
   }
 
+  if (invite.subscriptionId) {
+    const isGroupMember =
+      await SubscriptionGroupHandler.promises.isUserPartOfGroup(
+        currentUser._id,
+        invite.subscriptionId
+      )
+    if (!isGroupMember) {
+      throw new Errors.ForbiddenError(
+        'user is not part of subscription group required to accept invite'
+      )
+    }
+  }
+
+  // check if the user is already a member of the project and upgrade privileges if possible
+  if (currentUser) {
+    const currentPrivilegeLevel =
+      await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
+        currentUser._id,
+        projectId
+      )
+
+    if (currentPrivilegeLevel !== PrivilegeLevels.NONE) {
+      if (isPrivilegeUpgrade(currentPrivilegeLevel, invite.privileges)) {
+        logger.debug(
+          {
+            projectId,
+            userId: currentUser._id,
+            currentPrivilegeLevel,
+            invitePrivilegeLevel: invite.privileges,
+          },
+          'existing member of project can be upgraded'
+        )
+        await CollaboratorsInviteHandler.promises.upgradeUserPrivileges(
+          invite,
+          projectId,
+          currentUser
+        )
+      }
+      return res.redirect(`/project/${projectId}`)
+    }
+  }
+
+  if (invite.privileges === PrivilegeLevels.NONE) {
+    throw new Errors.NotFoundError(
+      'invite has been disabled and user is not an existing member of the project'
+    )
+  }
+
   await ProjectAuditLogHandler.promises.addEntry(
     projectId,
     'accept-invite',
@@ -392,6 +472,15 @@ async function acceptInvite(req, res) {
     currentUser
   )
 
+  // remove any other pending invites for user
+  const userEmails = await UserGetter.promises.getUserConfirmedEmails(
+    currentUser._id
+  )
+  await CollaboratorsInviteHandler.promises.revokeInviteForUser(
+    projectId,
+    userEmails
+  )
+
   await EditorRealTimeController.emitToRoom(
     projectId,
     'project:membership:changed',
@@ -412,7 +501,7 @@ async function acceptInvite(req, res) {
       ownerId: invite.sendingUserId, // only owner can invite others
       mode: editMode,
       role: invite.privileges,
-      source: 'email-invite',
+      source: urlToken ? 'email-invite' : 'sharing-link',
     }
   )
 
@@ -423,6 +512,187 @@ async function acceptInvite(req, res) {
   }
 }
 
+async function getSharingLink(req, res) {
+  const { Project_id: projectId } = req.params
+
+  const invite =
+    await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
+
+  if (invite === null || !invite.encryptedToken) {
+    res.sendStatus(404)
+  } else {
+    const token = await CollaboratorsInviteHelper.decryptToken(
+      invite.encryptedToken
+    )
+    res.json({
+      _id: invite._id,
+      token,
+      privileges: invite.privileges,
+      subscriptionId: invite.subscriptionId,
+    })
+  }
+}
+
+const updateSharingLinkSchema = z.object({
+  params: z.object({
+    Project_id: zz.objectId(),
+  }),
+  body: z.object({
+    // We have to use a union here, as Zod enums must be strings,
+    // but NONE is defined as boolean false
+    privileges: z.union([
+      z.literal(PrivilegeLevels.NONE),
+      z.enum([
+        PrivilegeLevels.READ_ONLY,
+        PrivilegeLevels.READ_AND_WRITE,
+        PrivilegeLevels.REVIEW,
+      ]),
+    ]),
+    subscriptionId: zz.objectId().optional(),
+  }),
+})
+
+async function updateSharingLink(req, res) {
+  const { params, body } = parseReq(req, updateSharingLinkSchema)
+  const projectId = params.Project_id
+  const privileges = body.privileges
+  const subscriptionId = body.subscriptionId
+
+  let invite =
+    await CollaboratorsInviteGetter.promises.getSharingLinkInvite(projectId)
+
+  const currentUser = SessionManager.getSessionUser(req.session)
+  if (invite === null) {
+    invite = await CollaboratorsInviteHandler.promises.createSharingLinkInvite(
+      projectId,
+      privileges,
+      subscriptionId
+    )
+    await ProjectAuditLogHandler.promises.addEntry(
+      projectId,
+      'sharing-link-created',
+      currentUser._id,
+      req.ip,
+      {
+        inviteId: invite._id,
+        privileges: invite.privileges,
+        subscriptionId: invite.subscriptionId,
+      }
+    )
+  } else {
+    invite.privileges = privileges
+    invite.subscriptionId = subscriptionId
+    await invite.save()
+    await ProjectAuditLogHandler.promises.addEntry(
+      projectId,
+      'sharing-link-updated',
+      currentUser._id,
+      req.ip,
+      {
+        inviteId: invite._id,
+        privileges: invite.privileges,
+        subscriptionId: invite.subscriptionId,
+      }
+    )
+  }
+
+  const token = await CollaboratorsInviteHelper.decryptToken(
+    invite.encryptedToken
+  )
+  res.json({
+    _id: invite._id,
+    token,
+    privileges: invite.privileges,
+    subscriptionId: invite.subscriptionId,
+  })
+}
+
+const validateSharingLinkSchema = z.object({
+  params: z.object({
+    Project_id: zz.objectId(),
+  }),
+  body: z.object({
+    token: z.string(),
+  }),
+})
+
+async function validateSharingLink(req, res) {
+  const { params, body } = parseReq(req, validateSharingLinkSchema)
+  const projectId = params.Project_id
+  const { token } = body
+
+  const invite = await CollaboratorsInviteGetter.promises.getInviteByToken(
+    projectId,
+    token
+  )
+
+  const currentUser = SessionManager.getSessionUser(req.session)
+
+  if (invite == null || !currentUser) {
+    return res.json({ valid: false })
+  }
+
+  const currentPrivilegeLevel =
+    await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
+      currentUser._id,
+      projectId
+    )
+
+  // fetch the project name
+  const project = await ProjectGetter.promises.getProject(projectId, {
+    name: 1,
+  })
+
+  // If the user is already a member, check if they should be redirected to the project page
+  // or shown the sharing link page based on whether the invite would upgrade their privileges
+  if (currentPrivilegeLevel !== PrivilegeLevels.NONE) {
+    if (
+      // User can't be upgraded any further
+      [PrivilegeLevels.OWNER, PrivilegeLevels.READ_AND_WRITE].includes(
+        currentPrivilegeLevel
+      ) ||
+      // Invite is either disabled or read-only, so can't be an upgrade
+      [PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE].includes(
+        invite.privileges
+      ) ||
+      // User already has the privileges of the invite
+      currentPrivilegeLevel === invite.privileges
+    ) {
+      return res.json({ valid: true, redirect: true })
+    } else {
+      return res.json({
+        valid: true,
+        projectName: project.name,
+        redirect: false,
+      })
+    }
+  }
+
+  if (invite.privileges === PrivilegeLevels.NONE) {
+    return res.json({ valid: false })
+  }
+
+  if (invite.subscriptionId) {
+    const isGroupMember =
+      await SubscriptionGroupHandler.promises.isUserPartOfGroup(
+        currentUser._id,
+        invite.subscriptionId
+      )
+    if (!isGroupMember) {
+      logger.debug(
+        {
+          projectId,
+          userId: currentUser._id,
+          subscriptionId: invite.subscriptionId,
+        },
+        'user is not part of subscription group required to use sharing link'
+      )
+      return res.json({ valid: false })
+    }
+  }
+  return res.json({ valid: true, projectName: project.name })
+}
+
 const CollaboratorsInviteController = {
   getAllInvites: expressify(getAllInvites),
   inviteToProject: expressify(inviteToProject),
@@ -430,6 +700,10 @@ const CollaboratorsInviteController = {
   generateNewInvite: expressify(generateNewInvite),
   viewInvite: expressify(viewInvite),
   acceptInvite: expressify(acceptInvite),
+  viewSharingLink: expressify(viewSharingLink),
+  getSharingLink: expressify(getSharingLink),
+  updateSharingLink: expressify(updateSharingLink),
+  validateSharingLink: expressify(validateSharingLink),
   _checkShouldInviteEmail,
   _checkRateLimit,
 }

+ 20 - 1
services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs

@@ -5,7 +5,10 @@ import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs'
 
 async function getAllInvites(projectId) {
   logger.debug({ projectId }, 'fetching invites for project')
-  const invites = await ProjectInvite.find({ projectId })
+  const invites = await ProjectInvite.find({
+    projectId,
+    reusable: { $ne: true },
+  })
     .select('_id email privileges')
     .exec()
   logger.debug(
@@ -39,10 +42,26 @@ async function getInviteByToken(projectId, tokenString) {
   return invite
 }
 
+async function getSharingLinkInvite(projectId) {
+  logger.debug({ projectId }, 'getting sharing link invite for project')
+  const invite = await ProjectInvite.findOne({
+    projectId,
+    reusable: true,
+  }).exec()
+
+  if (invite === null) {
+    logger.debug({ projectId }, 'no sharing link invite found for project')
+  } else {
+    logger.debug({ projectId }, 'found sharing link invite for project')
+  }
+  return invite
+}
+
 export default {
   promises: {
     getAllInvites,
     getEditInviteCount,
     getInviteByToken,
+    getSharingLinkInvite,
   },
 }

+ 66 - 12
services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs

@@ -5,10 +5,13 @@ import CollaboratorsEmailHandler from './CollaboratorsEmailHandler.mjs'
 import CollaboratorsHandler from './CollaboratorsHandler.mjs'
 import CollaboratorsInviteGetter from './CollaboratorsInviteGetter.mjs'
 import CollaboratorsInviteHelper from './CollaboratorsInviteHelper.mjs'
+import CollaboratorsGetter from './CollaboratorsGetter.mjs'
 import UserGetter from '../User/UserGetter.mjs'
 import ProjectGetter from '../Project/ProjectGetter.mjs'
 import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
-import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
+import PrivilegeLevels, {
+  isPrivilegeUpgrade,
+} from '../Authorization/PrivilegeLevels.mjs'
 import LimitationsManager from '../Subscription/LimitationsManager.mjs'
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import _ from 'lodash'
@@ -83,6 +86,7 @@ const CollaboratorsInviteHandler = {
       sendingUserId: sendingUser._id,
       projectId,
       privileges,
+      reusable: false,
     })
     invite = await invite.save()
     invite = invite.toObject()
@@ -200,18 +204,68 @@ const CollaboratorsInviteHandler = {
       opts
     )
 
-    // Remove invite
-    const inviteId = invite._id
-    logger.debug({ projectId, inviteId }, 'removing invite')
-    await ProjectInvite.deleteOne({ _id: inviteId }).exec()
-    CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch(
-      err => {
-        logger.error(
-          { err, projectId, inviteId },
-          'failed to cancel invite notification'
-        )
-      }
+    // Remove single-use invite
+    if (!invite.reusable) {
+      const inviteId = invite._id
+      logger.debug({ projectId, inviteId }, 'removing invite')
+      await ProjectInvite.deleteOne({ _id: inviteId }).exec()
+      CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId).catch(
+        err => {
+          logger.error(
+            { err, projectId, inviteId },
+            'failed to cancel invite notification'
+          )
+        }
+      )
+    }
+  },
+
+  async upgradeUserPrivileges(invite, projectId, user) {
+    const currentPrivilegeLevel =
+      await CollaboratorsGetter.promises.getMemberIdPrivilegeLevel(
+        user._id,
+        projectId
+      )
+    const invitePrivilegeLevel = invite.privileges
+
+    if (isPrivilegeUpgrade(currentPrivilegeLevel, invitePrivilegeLevel)) {
+      logger.debug(
+        {
+          projectId,
+          userId: user._id,
+          currentPrivilegeLevel,
+          invitePrivilegeLevel,
+        },
+        'upgrading user privileges'
+      )
+      await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+        projectId,
+        user._id,
+        invitePrivilegeLevel
+      )
+    }
+  },
+
+  async createSharingLinkInvite(projectId, privileges, subscriptionId) {
+    logger.debug(
+      { projectId, privileges, subscriptionId },
+      'adding sharing link invite'
     )
+    const token = CollaboratorsInviteHelper.generateToken()
+    const encryptedToken = await CollaboratorsInviteHelper.encryptToken(token)
+    const tokenHmac = CollaboratorsInviteHelper.hashInviteToken(token)
+    let invite = new ProjectInvite({
+      encryptedToken,
+      tokenHmac,
+      projectId,
+      privileges,
+      subscriptionId,
+      reusable: true,
+      expires: null,
+    })
+    invite = await invite.save()
+
+    return invite
   },
 }
 

+ 33 - 0
services/web/app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs

@@ -1,4 +1,7 @@
+import logger from '@overleaf/logger'
 import Crypto from 'node:crypto'
+import AccessTokenEncryptor from '@overleaf/access-token-encryptor'
+import Settings from '@overleaf/settings'
 
 function generateToken() {
   const buffer = Crypto.randomBytes(24)
@@ -24,8 +27,38 @@ function privilegeLevelToRole(privilegeLevel) {
   }
 }
 
+let accessTokenEncryptor
+try {
+  accessTokenEncryptor = new AccessTokenEncryptor(
+    Settings.projectInviteEncryptorOptions
+  )
+} catch (error) {
+  logger.error(
+    {},
+    'Failed to initialise Link Sharing token encryption. Please ensure OVERLEAF_INVITE_TOKEN_SECRET is set.'
+  )
+}
+
+async function encryptToken(token) {
+  if (!accessTokenEncryptor) {
+    throw new Error('Token encryption not configured, could not encrypt token')
+  }
+  logger.debug({ settings: Settings.projectInviteEncryptorOptions })
+  return accessTokenEncryptor.promises.encryptJson(token)
+}
+
+async function decryptToken(encryptedToken) {
+  if (!accessTokenEncryptor) {
+    throw new Error('Token encryption not configured, could not encrypt token')
+  }
+  logger.debug({ settings: Settings.projectInviteEncryptorOptions })
+  return accessTokenEncryptor.promises.decryptToJson(encryptedToken)
+}
+
 export default {
   generateToken,
   hashInviteToken,
   privilegeLevelToRole,
+  encryptToken,
+  decryptToken,
 }

+ 54 - 0
services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs

@@ -32,6 +32,10 @@ const rateLimiters = {
     points: 25, // just over view-project-invite
     duration: 60,
   }),
+  validateSharingLink: new RateLimiter('validate-sharing-link', {
+    points: 25, // just over view-project-invite
+    duration: 60,
+  }),
 }
 
 export default {
@@ -93,6 +97,20 @@ export default {
       CollaboratorsInviteController.getAllInvites
     )
 
+    webRouter.get(
+      '/project/:Project_id/sharing-link',
+      AuthenticationController.requireLogin(),
+      AuthorizationMiddleware.ensureUserCanAdminProject,
+      CollaboratorsInviteController.getSharingLink
+    )
+
+    webRouter.post(
+      '/project/:Project_id/sharing-link',
+      AuthenticationController.requireLogin(),
+      AuthorizationMiddleware.ensureUserCanAdminProject,
+      CollaboratorsInviteController.updateSharingLink
+    )
+
     webRouter.delete(
       '/project/:Project_id/invite/:invite_id',
       AuthenticationController.requireLogin(),
@@ -133,6 +151,42 @@ export default {
       AnalyticsRegistrationSourceMiddleware.clearSource()
     )
 
+    webRouter.get(
+      '/project/:Project_id/share',
+      AnalyticsRegistrationSourceMiddleware.setSource(
+        'collaboration',
+        'project-invite'
+      ),
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(rateLimiters.viewProjectInvite),
+      CollaboratorsInviteController.viewSharingLink,
+      AnalyticsRegistrationSourceMiddleware.clearSource()
+    )
+
+    webRouter.post(
+      '/project/:Project_id/share',
+      AnalyticsRegistrationSourceMiddleware.setSource(
+        'collaboration',
+        'project-invite'
+      ),
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(rateLimiters.acceptProjectInvite),
+      CollaboratorsInviteController.acceptInvite,
+      AnalyticsRegistrationSourceMiddleware.clearSource()
+    )
+
+    webRouter.post(
+      '/project/:Project_id/share/validate',
+      AnalyticsRegistrationSourceMiddleware.setSource(
+        'collaboration',
+        'project-invite'
+      ),
+      AuthenticationController.requireLogin(),
+      RateLimiterMiddleware.rateLimit(rateLimiters.validateSharingLink),
+      CollaboratorsInviteController.validateSharingLink,
+      AnalyticsRegistrationSourceMiddleware.clearSource()
+    )
+
     webRouter.get(
       '/project/:Project_id/tokens',
       RateLimiterMiddleware.rateLimit(rateLimiters.getProjectTokens),

+ 2 - 0
services/web/app/src/Features/Project/ProjectAuditLogHandler.mjs

@@ -23,6 +23,8 @@ const MANAGED_GROUP_PROJECT_EVENTS = [
   'revoke-invite',
   'toggle-access-level',
   'project-downloaded',
+  'sharing-link-created',
+  'sharing-link-updated',
 ]
 
 async function findManagedSubscriptions(entry) {

+ 5 - 12
services/web/app/src/Features/TokenAccess/TokenAccessController.mjs

@@ -6,7 +6,9 @@ import logger from '@overleaf/logger'
 import OError from '@overleaf/o-error'
 import { expressify } from '@overleaf/promise-utils'
 import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
-import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
+import PrivilegeLevels, {
+  isPrivilegeUpgrade,
+} from '../Authorization/PrivilegeLevels.mjs'
 import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
 import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
 import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.mjs'
@@ -24,13 +26,6 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
 
 const { getSafeAdminDomainRedirect } = UrlHelper
 const { canRedirectToAdminDomain } = AdminAuthorizationHelper
-const orderedPrivilegeLevels = [
-  PrivilegeLevels.NONE,
-  PrivilegeLevels.READ_ONLY,
-  PrivilegeLevels.REVIEW,
-  PrivilegeLevels.READ_AND_WRITE,
-  PrivilegeLevels.OWNER,
-]
 
 async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) {
   if (!Object.values(TokenAccessHandler.TOKEN_TYPES).includes(tokenType)) {
@@ -44,10 +39,7 @@ async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) {
       userId,
       projectId
     )
-  return (
-    orderedPrivilegeLevels.indexOf(privilegeLevel) >=
-    orderedPrivilegeLevels.indexOf(tokenType)
-  )
+  return !isPrivilegeUpgrade(privilegeLevel, tokenType)
 }
 
 const makePostUrl = token => {
@@ -240,6 +232,7 @@ async function checkAndGetProjectOrResponseAction(
     projectId,
     tokenType
   )
+  logger.debug({ userHasPrivilege }, 'checking user privilege')
   if (userHasPrivilege) {
     return [
       null,

+ 3 - 0
services/web/app/src/models/ProjectInvite.mjs

@@ -14,6 +14,7 @@ const ExpiryDate = function () {
 export const ProjectInviteSchema = new Schema(
   {
     email: String,
+    encryptedToken: String,
     tokenHmac: String,
     sendingUserId: ObjectId,
     projectId: ObjectId,
@@ -28,6 +29,8 @@ export const ProjectInviteSchema = new Schema(
       default: ExpiryDate,
       index: { expireAfterSeconds: 10 },
     },
+    reusable: { type: Boolean, default: false },
+    subscriptionId: ObjectId,
   },
   {
     collection: 'projectInvites',

+ 60 - 5
services/web/frontend/js/features/share-project/invite-root.tsx

@@ -5,6 +5,14 @@ import useAsync from '@/shared/hooks/use-async'
 import { postJSON } from '@/infrastructure/fetch-json'
 import { useLocation } from '@/shared/hooks/use-location'
 import { debugConsole } from '@/utils/debugging'
+import InviteNotValid from './invite-not-valid'
+import { useEffect, useState } from 'react'
+
+type ValidateResponse = {
+  valid: boolean
+  projectName?: string
+  redirect?: boolean
+}
 
 export default function InviteRoot() {
   const user = getMeta('ol-user')
@@ -12,22 +20,69 @@ export default function InviteRoot() {
   const projectId = getMeta('ol-project_id')
   const token = getMeta('ol-inviteToken')
   const location = useLocation()
+  const hashToken = document.location.hash.replace('#', '')
   const { isLoading, runAsync } = useAsync()
   const { isReady } = useWaitForI18n()
+  const [validateResponse, setValidateResponse] = useState<ValidateResponse>({
+    valid: false,
+  })
+
+  // If a sharing link (ie. token is in hash), validate the user can use this link
+  useEffect(() => {
+    const validate = async () => {
+      if (hashToken) {
+        try {
+          const result = await runAsync(
+            postJSON(`/project/${projectId}/share/validate`, {
+              body: { token: hashToken },
+            })
+          )
+          setValidateResponse(result)
+          if (result.redirect) {
+            location.replace(`/project/${projectId}`)
+          }
+        } catch (err) {
+          debugConsole.error('Error validating sharing link', err)
+          setValidateResponse({ valid: false })
+        }
+      } else {
+        setValidateResponse({ valid: false })
+      }
+    }
+    // Always treat URL tokens as valid, they are validated on the backend before
+    // rendering this page.
+    if (token) {
+      setValidateResponse({ valid: true })
+      return
+    }
+    validate()
+  }, [token, hashToken, projectId, runAsync, location])
 
   const handleSubmit = () => {
-    runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`))
-      .then(() => location.assign(`/project/${projectId}`))
-      .catch(debugConsole.error)
+    if (token) {
+      runAsync(postJSON(`/project/${projectId}/invite/token/${token}/accept`))
+        .then(() => location.assign(`/project/${projectId}`))
+        .catch(debugConsole.error)
+    } else if (hashToken) {
+      runAsync(
+        postJSON(`/project/${projectId}/share`, { body: { token: hashToken } })
+      )
+        .then(() => location.assign(`/project/${projectId}`))
+        .catch(debugConsole.error)
+    }
   }
 
-  if (!isReady) {
+  if (!isReady || isLoading || validateResponse.redirect) {
     return null
   }
 
+  if ((!token && !hashToken) || !validateResponse.valid) {
+    return <InviteNotValid email={user?.email} />
+  }
+
   return (
     <Invite
-      projectName={projectName}
+      projectName={validateResponse.projectName || projectName}
       email={user.email}
       submitHandler={handleSubmit}
       isLoading={isLoading}

+ 7 - 0
services/web/test/acceptance/config/settings.test.defaults.js

@@ -294,6 +294,13 @@ module.exports = {
   devToolbar: {
     enabled: false,
   },
+
+  projectInviteEncryptorOptions: {
+    cipherLabel: '2026.3-v3',
+    cipherPasswords: {
+      '2026.3-v3': 'this-is-a-weak-secret-for-tests-web-2026.3-v3',
+    },
+  },
 }
 
 module.exports.mergeWith = function (overrides) {

+ 291 - 0
services/web/test/acceptance/src/ProjectInviteTests.mjs

@@ -1,6 +1,7 @@
 import { expect } from 'chai'
 import Async from 'async'
 import User from './helpers/User.mjs'
+import Subscription from './helpers/Subscription.mjs'
 import settings from '@overleaf/settings'
 import CollaboratorsEmailHandler from '../../../app/src/Features/Collaborators/CollaboratorsEmailHandler.mjs'
 import CollaboratorsInviteHelper from '../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs'
@@ -156,6 +157,98 @@ const tryGetInviteList = (user, projectId, callback) => {
   })
 }
 
+const updateSharingLink = (
+  sendingUser,
+  projectId,
+  privileges,
+  opts,
+  callback
+) => {
+  if (typeof opts === 'function') {
+    callback = opts
+    opts = {}
+  }
+  sendingUser.getCsrfToken(err => {
+    if (err) {
+      return callback(err)
+    }
+    sendingUser.request.post(
+      {
+        uri: `/project/${projectId}/sharing-link`,
+        json: {
+          privileges,
+          ...opts,
+        },
+      },
+      (err, response, body) => {
+        if (err) {
+          return callback(err)
+        }
+        expect(response.statusCode).to.equal(200)
+        expect(body).to.include.keys('_id', 'token', 'privileges')
+        callback(null, body)
+      }
+    )
+  })
+}
+
+const getSharingLink = (sendingUser, projectId, callback) => {
+  sendingUser.getCsrfToken(err => {
+    if (err) {
+      return callback(err)
+    }
+    sendingUser.request.get(
+      {
+        uri: `/project/${projectId}/sharing-link`,
+        json: true,
+      },
+      (err, response, body) => {
+        if (err) {
+          return callback(err)
+        }
+        callback(null, response, body)
+      }
+    )
+  })
+}
+
+const validateSharingLink = (user, projectId, token, callback) => {
+  user.getCsrfToken(err => {
+    if (err) {
+      return callback(err)
+    }
+    user.request.post(
+      {
+        uri: `/project/${projectId}/share/validate`,
+        json: { token },
+      },
+      (err, response, body) => {
+        if (err) {
+          return callback(err)
+        }
+        callback(null, response, body)
+      }
+    )
+  })
+}
+
+const acceptSharingLink = (user, projectId, token, callback) => {
+  user.getCsrfToken(err => {
+    if (err) {
+      return callback(err)
+    }
+    user.request.post(
+      {
+        uri: `/project/${projectId}/share`,
+        json: {
+          token,
+        },
+      },
+      callback
+    )
+  })
+}
+
 const tryJoinProject = (user, projectId, callback) => {
   return user.getCsrfToken(error => {
     if (error != null) {
@@ -852,4 +945,202 @@ describe('ProjectInviteTests', function () {
       })
     })
   })
+
+  describe('sharing link routes', function () {
+    beforeEach(function (done) {
+      this.projectName = `sharing-link-test-${Math.random()}`
+      createProject(this.sendingUser, this.projectName, (err, projectId) => {
+        if (err) {
+          return done(err)
+        }
+        this.projectId = projectId
+        done()
+      })
+    })
+
+    it('should create and fetch sharing link without listing reusable invite in invite list', function (done) {
+      Async.series(
+        [
+          cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
+          cb => {
+            updateSharingLink(
+              this.sendingUser,
+              this.projectId,
+              'readOnly',
+              (err, link) => {
+                if (err) {
+                  return cb(err)
+                }
+                this.sharingLink = link
+                expect(link.privileges).to.equal('readOnly')
+                cb()
+              }
+            )
+          },
+          cb => expectInviteListCount(this.sendingUser, this.projectId, 0, cb),
+          cb => {
+            getSharingLink(
+              this.sendingUser,
+              this.projectId,
+              (err, response, body) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(body.privileges).to.equal('readOnly')
+                expect(body.token).to.equal(this.sharingLink.token)
+                cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should validate and accept sharing link via /share flow', function (done) {
+      Async.series(
+        [
+          cb => this.user.login(cb),
+          cb => {
+            updateSharingLink(
+              this.sendingUser,
+              this.projectId,
+              'readOnly',
+              (err, link) => {
+                if (err) {
+                  return cb(err)
+                }
+                this.sharingLink = link
+                cb()
+              }
+            )
+          },
+          cb => expectNoProjectAccess(this.user, this.projectId, cb),
+          cb => {
+            validateSharingLink(
+              this.user,
+              this.projectId,
+              this.sharingLink.token,
+              (err, response, body) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(body.valid).to.equal(true)
+                cb()
+              }
+            )
+          },
+          cb => {
+            acceptSharingLink(
+              this.user,
+              this.projectId,
+              this.sharingLink.token,
+              (err, response) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(302)
+                expect(response.headers.location).to.equal(
+                  `/project/${this.projectId}`
+                )
+                cb()
+              }
+            )
+          },
+          cb => expectProjectAccess(this.user, this.projectId, cb),
+        ],
+        done
+      )
+    })
+
+    it('should reject invalid sharing link token in validation', function (done) {
+      Async.series(
+        [
+          cb => this.user.login(cb),
+          cb => {
+            validateSharingLink(
+              this.user,
+              this.projectId,
+              'not-a-valid-token',
+              (err, response, body) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(body.valid).to.equal(false)
+                cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should reject validation for user not in required subscription group', function (done) {
+      if (!Features.hasFeature('saas')) {
+        this.skip()
+      }
+      const subscription = new Subscription({
+        adminId: this.sendingUser._id,
+        memberIds: [this.sendingUser._id],
+      })
+      let sharingLink
+      Async.series(
+        [
+          cb => this.user.login(cb),
+          cb => subscription.ensureExists(cb),
+          cb => {
+            updateSharingLink(
+              this.sendingUser,
+              this.projectId,
+              'readOnly',
+              { subscriptionId: subscription._id.toString() },
+              (err, link) => {
+                if (err) {
+                  return cb(err)
+                }
+                sharingLink = link
+                cb()
+              }
+            )
+          },
+          cb => {
+            validateSharingLink(
+              this.user,
+              this.projectId,
+              sharingLink.token,
+              (err, response, body) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(body.valid).to.equal(false)
+                cb()
+              }
+            )
+          },
+          cb => subscription.addMember(this.user._id, cb),
+          cb => {
+            validateSharingLink(
+              this.user,
+              this.projectId,
+              sharingLink.token,
+              (err, response, body) => {
+                if (err) {
+                  return cb(err)
+                }
+                expect(response.statusCode).to.equal(200)
+                expect(body.valid).to.equal(true)
+                cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+  })
 })

+ 127 - 0
services/web/test/unit/src/Authorization/PrivilegeLevels.test.mjs

@@ -0,0 +1,127 @@
+import { expect } from 'vitest'
+import Errors from '../../../../app/src/Features/Errors/Errors.js'
+import PrivilegeLevels, {
+  isPrivilegeUpgrade,
+} from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
+
+describe('PrivilegeLevels', function () {
+  describe('isPrivilegeUpgrade', function () {
+    it('returns true when upgrading from NONE to READ_ONLY', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.READ_ONLY)
+      ).to.be.true
+    })
+
+    it('returns true when upgrading from READ_ONLY to REVIEW', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW)
+      ).to.be.true
+    })
+
+    it('returns true when upgrading from REVIEW to READ_AND_WRITE', function () {
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.REVIEW,
+          PrivilegeLevels.READ_AND_WRITE
+        )
+      ).to.be.true
+    })
+
+    it('returns true when upgrading from READ_AND_WRITE to OWNER', function () {
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.READ_AND_WRITE,
+          PrivilegeLevels.OWNER
+        )
+      ).to.be.true
+    })
+
+    it('returns true when upgrading by more than one step', function () {
+      expect(isPrivilegeUpgrade(PrivilegeLevels.NONE, PrivilegeLevels.OWNER)).to
+        .be.true
+    })
+
+    it('returns false when the levels are equal', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.READ_ONLY)
+      ).to.be.false
+      expect(isPrivilegeUpgrade(PrivilegeLevels.OWNER, PrivilegeLevels.OWNER))
+        .to.be.false
+    })
+
+    it('returns false when downgrading from OWNER to READ_AND_WRITE', function () {
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.OWNER,
+          PrivilegeLevels.READ_AND_WRITE
+        )
+      ).to.be.false
+    })
+
+    it('returns false when downgrading from READ_AND_WRITE to REVIEW', function () {
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.READ_AND_WRITE,
+          PrivilegeLevels.REVIEW
+        )
+      ).to.be.false
+    })
+
+    it('returns false when downgrading from REVIEW to READ_ONLY', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.REVIEW, PrivilegeLevels.READ_ONLY)
+      ).to.be.false
+    })
+
+    it('returns false when downgrading to NONE', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.NONE)
+      ).to.be.false
+    })
+
+    it('treats REVIEW as higher than READ_ONLY but lower than READ_AND_WRITE', function () {
+      expect(
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, PrivilegeLevels.REVIEW)
+      ).to.be.true
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.REVIEW,
+          PrivilegeLevels.READ_AND_WRITE
+        )
+      ).to.be.true
+      expect(
+        isPrivilegeUpgrade(
+          PrivilegeLevels.READ_AND_WRITE,
+          PrivilegeLevels.REVIEW
+        )
+      ).to.be.false
+    })
+
+    it('throws InvalidError when currentLevel is not a known privilege level', function () {
+      expect(() =>
+        isPrivilegeUpgrade('bogus', PrivilegeLevels.READ_ONLY)
+      ).to.throw(Errors.InvalidError)
+    })
+
+    it('throws InvalidError when newLevel is not a known privilege level', function () {
+      expect(() =>
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, 'bogus')
+      ).to.throw(Errors.InvalidError)
+    })
+
+    it('throws InvalidError when both levels are invalid', function () {
+      expect(() => isPrivilegeUpgrade('foo', 'bar')).to.throw(
+        Errors.InvalidError
+      )
+    })
+
+    it('throws InvalidError for undefined inputs', function () {
+      expect(() =>
+        isPrivilegeUpgrade(undefined, PrivilegeLevels.READ_ONLY)
+      ).to.throw(Errors.InvalidError)
+      expect(() =>
+        isPrivilegeUpgrade(PrivilegeLevels.READ_ONLY, undefined)
+      ).to.throw(Errors.InvalidError)
+    })
+  })
+})

+ 1 - 1
services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs

@@ -629,7 +629,7 @@ describe('CollaboratorsGetter', function () {
         expect(privilegeLevel).to.equal('readOnly')
       })
 
-      it('should return none for non-members', function (ctx) {
+      it('should return false for non-members', function (ctx) {
         const projectAccess = new ctx.CollaboratorsGetter.ProjectAccess(
           ctx.project
         )

+ 193 - 3
services/web/test/unit/src/Collaborators/CollaboratorsInviteController.test.mjs

@@ -71,18 +71,22 @@ describe('CollaboratorsInviteController', function () {
       promises: {
         getUserByAnyEmail: sinon.stub(),
         getUser: sinon.stub().resolves(ctx.currentUser),
+        getUserConfirmedEmails: sinon
+          .stub()
+          .resolves([{ email: ctx.currentUser.email }]),
       },
     }
 
     ctx.ProjectGetter = {
       promises: {
-        getProject: sinon.stub(),
+        getProject: sinon.stub().resolves(ctx.project),
       },
     }
 
     ctx.CollaboratorsGetter = {
       promises: {
         isUserInvitedMemberOfProject: sinon.stub(),
+        getMemberIdPrivilegeLevel: sinon.stub().resolves(false),
       },
     }
 
@@ -92,6 +96,9 @@ describe('CollaboratorsInviteController', function () {
         generateNewInvite: sinon.stub().resolves(ctx.invite),
         revokeInvite: sinon.stub().resolves(ctx.invite),
         acceptInvite: sinon.stub(),
+        upgradeUserPrivileges: sinon.stub().resolves(),
+        createSharingLinkInvite: sinon.stub().resolves(ctx.invite),
+        revokeInviteForUser: sinon.stub().resolves(),
       },
     }
 
@@ -99,9 +106,26 @@ describe('CollaboratorsInviteController', function () {
       promises: {
         getAllInvites: sinon.stub(),
         getInviteByToken: sinon.stub().resolves(ctx.invite),
+        getSharingLinkInvite: sinon.stub().resolves(ctx.invite),
       },
     }
 
+    ctx.CollaboratorsInviteHelper = {
+      decryptToken: sinon.stub().resolves(ctx.token),
+      privilegeLevelToRole: sinon.stub().callsFake(privilege => {
+        if (privilege === 'readOnly') {
+          return 'Viewer'
+        }
+        if (privilege === 'readAndWrite') {
+          return 'Editor'
+        }
+        if (privilege === 'review') {
+          return 'Reviewer'
+        }
+        return privilege
+      }),
+    }
+
     ctx.EditorRealTimeController = {
       emitToRoom: sinon.stub(),
     }
@@ -119,6 +143,12 @@ describe('CollaboratorsInviteController', function () {
       setRedirectInSession: sinon.stub(),
     }
 
+    ctx.SubscriptionGroupHandler = {
+      promises: {
+        isUserPartOfGroup: sinon.stub().resolves(true),
+      },
+    }
+
     ctx.SplitTestHandler = {
       promises: {
         getAssignment: sinon.stub().resolves({ variant: 'default' }),
@@ -169,6 +199,13 @@ describe('CollaboratorsInviteController', function () {
       })
     )
 
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsInviteHelper.mjs',
+      () => ({
+        default: ctx.CollaboratorsInviteHelper,
+      })
+    )
+
     vi.doMock(
       '../../../../app/src/Features/Editor/EditorRealTimeController.mjs',
       () => ({
@@ -200,19 +237,26 @@ describe('CollaboratorsInviteController', function () {
     )
 
     vi.doMock(
-      '../../../../app/src/Features/Authentication/AuthenticationController',
+      '../../../../app/src/Features/Authentication/AuthenticationController.mjs',
       () => ({
         default: ctx.AuthenticationController,
       })
     )
 
     vi.doMock(
-      '../../../../app/src/Features/SplitTests/SplitTestHandler',
+      '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
       () => ({
         default: ctx.SplitTestHandler,
       })
     )
 
+    vi.doMock(
+      '../../../../app/src/Features/Subscription/SubscriptionGroupHandler.mjs',
+      () => ({
+        default: ctx.SubscriptionGroupHandler,
+      })
+    )
+
     ctx.CollaboratorsInviteController = (await import(MODULE_PATH)).default
 
     ctx.res = new MockResponse(vi)
@@ -1645,6 +1689,10 @@ describe('CollaboratorsInviteController', function () {
         Project_id: ctx.projectId,
         token: ctx.token,
       }
+      ctx.req.body = {}
+      ctx.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves(
+        false
+      )
     })
 
     describe('when acceptInvite does not produce an error', function () {
@@ -1695,6 +1743,25 @@ describe('CollaboratorsInviteController', function () {
           }
         )
       })
+
+      it('records sharing-link source when token comes from request body', async function (ctx) {
+        await new Promise(resolve => {
+          ctx.req.params.token = undefined
+          ctx.req.body = { token: ctx.token }
+          ctx.res.callback = () => resolve()
+          ctx.CollaboratorsInviteController.acceptInvite(
+            ctx.req,
+            ctx.res,
+            ctx.next
+          )
+        })
+
+        ctx.AnalyticsManger.recordEventForUserInBackground.should.have.been.calledWith(
+          ctx.currentUser._id,
+          'project-joined',
+          sinon.match({ source: 'sharing-link' })
+        )
+      })
     })
 
     describe('when the invite is not found', function () {
@@ -1770,6 +1837,129 @@ describe('CollaboratorsInviteController', function () {
     })
   })
 
+  describe('getSharingLink', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.invite.encryptedToken = 'encrypted-token'
+      ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
+        ctx.invite
+      )
+      ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token)
+    })
+
+    it('returns sharing link payload when reusable invite exists', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res)
+      })
+
+      expect(ctx.res.json).toHaveBeenCalledWith({
+        _id: ctx.invite._id,
+        token: ctx.token,
+        privileges: ctx.invite.privileges,
+        subscriptionId: ctx.invite.subscriptionId,
+      })
+    })
+
+    it('returns 404 when sharing link does not exist', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
+          null
+        )
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.getSharingLink(ctx.req, ctx.res)
+      })
+
+      expect(ctx.res.sendStatus).toHaveBeenCalledWith(404)
+    })
+  })
+
+  describe('updateSharingLink', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.req.body = {
+        privileges: 'readOnly',
+      }
+      ctx.invite.encryptedToken = 'encrypted-token'
+      ctx.invite.save = sinon.stub().resolves(ctx.invite)
+      ctx.CollaboratorsInviteHelper.decryptToken.resolves(ctx.token)
+    })
+
+    it('creates sharing link invite when none exists', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
+          null
+        )
+        ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.resolves(
+          ctx.invite
+        )
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
+      })
+
+      ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite.should.have.been.calledWith(
+        ctx.projectId,
+        'readOnly',
+        undefined
+      )
+    })
+
+    it('updates existing sharing link invite', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite.resolves(
+          ctx.invite
+        )
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.updateSharingLink(ctx.req, ctx.res)
+      })
+
+      expect(ctx.invite.save).to.have.been.calledOnce
+      expect(ctx.res.json).toHaveBeenCalledTimes(1)
+    })
+  })
+
+  describe('validateSharingLink', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params = { Project_id: ctx.projectId }
+      ctx.req.body = { token: ctx.token }
+    })
+
+    it('returns valid false when invite not found', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null)
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
+      })
+      expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
+    })
+
+    it('returns valid true for logged in user without subscription restriction', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
+          ctx.invite
+        )
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
+      })
+      expect(ctx.res.json).toHaveBeenCalledWith({
+        valid: true,
+      })
+    })
+
+    it('returns valid false when subscription group check fails', async function (ctx) {
+      await new Promise(resolve => {
+        ctx.invite.subscriptionId = new ObjectId().toString()
+        ctx.SubscriptionGroupHandler.promises.isUserPartOfGroup.resolves(false)
+        ctx.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(
+          ctx.invite
+        )
+        ctx.res.callback = () => resolve()
+        ctx.CollaboratorsInviteController.validateSharingLink(ctx.req, ctx.res)
+      })
+      expect(ctx.res.json).toHaveBeenCalledWith({ valid: false })
+    })
+  })
+
   describe('_checkShouldInviteEmail', function () {
     beforeEach(function (ctx) {
       ctx.email = 'user@example.com'

+ 41 - 1
services/web/test/unit/src/Collaborators/CollaboratorsInviteGetter.test.mjs

@@ -36,6 +36,7 @@ describe('CollaboratorsInviteGetter', function () {
     ctx.CollaboratorsInviteHelper = {
       generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)),
       hashInviteToken: sinon.stub().returns(ctx.tokenHmac),
+      decryptToken: sinon.stub().resolves(ctx.token),
     }
 
     vi.doMock('../../../../app/src/models/ProjectInvite', () => ({
@@ -134,7 +135,7 @@ describe('CollaboratorsInviteGetter', function () {
         await ctx.call()
         ctx.ProjectInvite.find.callCount.should.equal(1)
         ctx.ProjectInvite.find
-          .calledWith({ projectId: ctx.projectId })
+          .calledWith({ projectId: ctx.projectId, reusable: { $ne: true } })
           .should.equal(true)
       })
     })
@@ -206,4 +207,43 @@ describe('CollaboratorsInviteGetter', function () {
       })
     })
   })
+
+  describe('getSharingLinkInvite', function () {
+    beforeEach(function (ctx) {
+      ctx.ProjectInvite.findOne.returns({
+        exec: sinon.stub().resolves(ctx.fakeInvite),
+      })
+      ctx.call = async () => {
+        return await ctx.CollaboratorsInviteGetter.promises.getSharingLinkInvite(
+          ctx.projectId
+        )
+      }
+    })
+
+    it('returns the sharing link invite', async function (ctx) {
+      const invite = await ctx.call()
+      expect(invite).to.deep.equal(ctx.fakeInvite)
+    })
+
+    it('queries reusable invite records', async function (ctx) {
+      await ctx.call()
+      ctx.ProjectInvite.findOne.should.have.been.calledWith({
+        projectId: ctx.projectId,
+        reusable: true,
+      })
+    })
+
+    describe('when no reusable invite exists', function () {
+      beforeEach(function (ctx) {
+        ctx.ProjectInvite.findOne.returns({
+          exec: sinon.stub().resolves(null),
+        })
+      })
+
+      it('returns null', async function (ctx) {
+        const invite = await ctx.call()
+        expect(invite).to.be.null
+      })
+    })
+  })
 })

+ 87 - 5
services/web/test/unit/src/Collaborators/CollaboratorsInviteHandler.test.mjs

@@ -37,6 +37,7 @@ describe('CollaboratorsInviteHandler', function () {
     ctx.CollaboratorsHandler = {
       promises: {
         addUserIdToProject: sinon.stub(),
+        setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
       },
     }
     ctx.UserGetter = { promises: { getUser: sinon.stub() } }
@@ -46,6 +47,7 @@ describe('CollaboratorsInviteHandler', function () {
     ctx.CollaboratorsInviteHelper = {
       generateToken: sinon.stub().returns(ctx.Crypto.randomBytes(24)),
       hashInviteToken: sinon.stub().returns(ctx.tokenHmac),
+      encryptToken: sinon.stub().resolves('encrypted-token'),
     }
 
     ctx.CollaboratorsInviteGetter = {
@@ -54,6 +56,12 @@ describe('CollaboratorsInviteHandler', function () {
       },
     }
 
+    ctx.CollaboratorsGetter = {
+      promises: {
+        getMemberIdPrivilegeLevel: sinon.stub().resolves('readOnly'),
+      },
+    }
+
     ctx.SplitTestHandler = {
       promises: {
         getAssignmentForUser: sinon.stub().resolves(),
@@ -76,6 +84,7 @@ describe('CollaboratorsInviteHandler', function () {
       debug: sinon.stub(),
       warn: sinon.stub(),
       err: sinon.stub(),
+      error: sinon.stub(),
     }
 
     vi.doMock('@overleaf/settings', () => ({
@@ -127,12 +136,19 @@ describe('CollaboratorsInviteHandler', function () {
     )
 
     vi.doMock(
-      '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter',
+      '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.mjs',
       () => ({
         default: ctx.CollaboratorsInviteGetter,
       })
     )
 
+    vi.doMock(
+      '../../../../app/src/Features/Collaborators/CollaboratorsGetter.mjs',
+      () => ({
+        default: ctx.CollaboratorsGetter,
+      })
+    )
+
     vi.doMock(
       '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
       () => ({
@@ -154,10 +170,6 @@ describe('CollaboratorsInviteHandler', function () {
       })
     )
 
-    vi.doMock('crypto', () => ({
-      default: ctx.CryptogetAssignmentForUser,
-    }))
-
     ctx.CollaboratorsInviteHandler = (await import(MODULE_PATH)).default
 
     ctx.projectId = new ObjectId()
@@ -600,6 +612,12 @@ describe('CollaboratorsInviteHandler', function () {
           )
           .should.equal(true)
       })
+
+      it('does not remove reusable invites after accept', async function (ctx) {
+        ctx.fakeInvite.reusable = true
+        await ctx.call()
+        ctx.ProjectInvite.deleteOne.callCount.should.equal(0)
+      })
     })
 
     describe('when the project has no more edit collaborator slots', function () {
@@ -692,6 +710,70 @@ describe('CollaboratorsInviteHandler', function () {
     })
   })
 
+  describe('upgradeUserPrivileges', function () {
+    beforeEach(function (ctx) {
+      ctx.call = async () => {
+        await ctx.CollaboratorsInviteHandler.promises.upgradeUserPrivileges(
+          ctx.fakeInvite,
+          ctx.projectId,
+          ctx.user
+        )
+      }
+      ctx.fakeInvite.privileges = 'readAndWrite'
+    })
+
+    it('upgrades the user when invite privileges are higher', async function (ctx) {
+      ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves(
+        'readOnly'
+      )
+      await ctx.call()
+      ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.should.have.been.calledWith(
+        ctx.projectId,
+        ctx.user._id,
+        'readAndWrite'
+      )
+    })
+
+    it('does not update when invite is readOnly', async function (ctx) {
+      ctx.fakeInvite.privileges = 'readOnly'
+      await ctx.call()
+      expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
+        .not.have.been.called
+    })
+
+    it('does not downgrade when invite is disabled (none)', async function (ctx) {
+      ctx.fakeInvite.privileges = false
+      ctx.CollaboratorsGetter.promises.getMemberIdPrivilegeLevel.resolves(
+        'readOnly'
+      )
+      await ctx.call()
+      expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to
+        .not.have.been.called
+    })
+  })
+
+  describe('createSharingLinkInvite', function () {
+    beforeEach(function (ctx) {
+      ctx.ProjectInvite.prototype.save.callsFake(async function () {
+        return this
+      })
+      ctx.call = async () => {
+        return await ctx.CollaboratorsInviteHandler.promises.createSharingLinkInvite(
+          ctx.projectId,
+          'readOnly',
+          undefined
+        )
+      }
+    })
+
+    it('creates reusable invite with encrypted token', async function (ctx) {
+      const invite = await ctx.call()
+      expect(ctx.CollaboratorsInviteHelper.encryptToken).to.have.been.calledOnce
+      expect(invite.reusable).to.equal(true)
+      expect(invite.encryptedToken).to.equal('encrypted-token')
+    })
+  })
+
   describe('_tryCancelInviteNotification', function () {
     beforeEach(function (ctx) {
       ctx.inviteId = new ObjectId()