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

[web] Support link sharing using ProjectInvite

GitOrigin-RevId: 45d988dd6451f962f2709bb1e72a715570816d9c
This commit is contained in:
MoxAmber
2026-06-10 08:06:04 +00:00
committed by Copybot
parent b90a8500d5
commit 28210bcaeb
19 changed files with 1323 additions and 59 deletions
+10
View File
@@ -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) {
@@ -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
View File
@@ -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
@@ -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,
}
@@ -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,
},
}
@@ -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
},
}
@@ -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,
}
@@ -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),
@@ -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) {
@@ -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,
@@ -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',
@@ -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}
@@ -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) {
@@ -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
)
})
})
})
@@ -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)
})
})
})
@@ -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
)
@@ -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'
@@ -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
})
})
})
})
@@ -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()