diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index eb2cb5153b..75c512d26b 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -3,6 +3,7 @@ import HttpErrorHandler from '../../Features/Errors/HttpErrorHandler.mjs' import mongodb from 'mongodb-legacy' import CollaboratorsHandler from './CollaboratorsHandler.mjs' import CollaboratorsGetter from './CollaboratorsGetter.mjs' +import CollaboratorsInviteHandler from './CollaboratorsInviteHandler.mjs' import OwnershipTransferHandler from './OwnershipTransferHandler.mjs' import SessionManager from '../Authentication/SessionManager.mjs' import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs' @@ -15,6 +16,10 @@ import TokenAccessHandler from '../TokenAccess/TokenAccessHandler.mjs' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import LimitationsManager from '../Subscription/LimitationsManager.mjs' import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs' +import AuthorizationManager from '../Authorization/AuthorizationManager.mjs' +import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' +import EmailHandler from '../Email/EmailHandler.mjs' +import ProjectGetter from '../Project/ProjectGetter.mjs' import { z, zz, parseReq } from '../../infrastructure/Validation.mjs' import Features from '../../infrastructure/Features.mjs' import UserGetter from '../User/UserGetter.mjs' @@ -29,6 +34,10 @@ export default { setCollaboratorInfo: expressify(setCollaboratorInfo), transferOwnership: expressify(transferOwnership), getShareTokens: expressify(getShareTokens), + getAccessRequests: expressify(getAccessRequests), + requestAccess: expressify(requestAccess), + declineAccessRequest: expressify(declineAccessRequest), + grantAccessRequest: expressify(grantAccessRequest), } async function removeUserFromProject(req, res, next) { @@ -89,6 +98,17 @@ async function getAllMembers(req, res, next) { res.json({ members }) } +// The pending access requests carry requester identities, so this is admin +// gated by the router (ensureUserCanAdminProject). A requester's own +// `myAccessRequest` is delivered via the editor bootstrap instead. +async function getAccessRequests(req, res) { + const projectId = req.params.Project_id + const projectAccess = + await CollaboratorsGetter.promises.getProjectAccess(projectId) + const editAccessRequests = await projectAccess.loadAccessRequestsView() + res.json({ editAccessRequests }) +} + const setCollaboratorInfoSchema = z.object({ params: z.object({ Project_id: zz.objectId(), @@ -193,6 +213,290 @@ async function transferOwnership(req, res, next) { } } +const requestAccessSchema = z.object({ + params: z.object({ + Project_id: zz.objectId(), + }), + body: z.object({ + privilegeLevel: z.enum([ + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + }), +}) + +// Which privilege levels a caller may request, keyed by the level they +// currently hold. Viewers can ask for editor or reviewer; reviewers can only +// ask for editor (the one level above them). Anyone else (owner / editor) +// has nothing to request. +const REQUESTABLE_LEVELS_BY_CURRENT_LEVEL = { + [PrivilegeLevels.READ_ONLY]: [ + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ], + [PrivilegeLevels.REVIEW]: [PrivilegeLevels.READ_AND_WRITE], +} + +async function requestAccess(req, res) { + const { params, body } = parseReq(req, requestAccessSchema) + const projectId = params.Project_id + const { privilegeLevel } = body + const userId = SessionManager.getLoggedInUserId(req.session) + const currentPrivilegeLevel = + await AuthorizationManager.promises.getPrivilegeLevelForProject( + userId, + projectId + ) + const requestableLevels = + REQUESTABLE_LEVELS_BY_CURRENT_LEVEL[currentPrivilegeLevel] + if (!requestableLevels?.includes(privilegeLevel)) { + return HttpErrorHandler.forbidden(req, res) + } + const { isNew } = await CollaboratorsHandler.promises.requestAccess( + projectId, + userId, + privilegeLevel + ) + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + accessRequests: true, + }) + if (isNew) { + _notifyOwnerOfAccessRequest(projectId, userId, privilegeLevel).catch( + err => { + logger.error( + { err, projectId, userId, privilegeLevel }, + 'failed to notify owner of access request' + ) + } + ) + AnalyticsManager.recordEventForUserInBackground( + userId, + 'project-access-requested', + { projectId, currentPrivilegeLevel, privilegeLevel } + ) + } + res.sendStatus(204) +} + +const declineAccessRequestSchema = z.object({ + params: z.object({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), + body: z.object({ + notify: z.boolean().optional(), + }), +}) + +async function declineAccessRequest(req, res) { + const { params, body } = parseReq(req, declineAccessRequestSchema) + const projectId = params.Project_id + const userId = params.user_id + const notify = body.notify === true + const sessionUserId = SessionManager.getLoggedInUserId(req.session) + const { removed, privilegeLevel } = + await CollaboratorsHandler.promises.declineAccessRequest(projectId, userId) + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + accessRequests: true, + }) + if (removed) { + AnalyticsManager.recordEventForUserInBackground( + sessionUserId, + 'project-access-request-actioned', + { projectId, privilegeLevel, decision: 'deny' } + ) + if (notify) { + _notifyRequesterOfOutcome( + projectId, + userId, + 'declined', + privilegeLevel + ).catch(err => { + logger.error( + { err, projectId, userId }, + 'failed to notify requester of declined access request' + ) + }) + } + } + res.sendStatus(204) +} + +const grantAccessRequestSchema = z.object({ + params: z.object({ + Project_id: zz.objectId(), + user_id: zz.objectId(), + }), + body: z.object({ + privilegeLevel: z.enum([ + PrivilegeLevels.READ_AND_WRITE, + PrivilegeLevels.REVIEW, + ]), + notify: z.boolean().optional(), + }), +}) + +async function grantAccessRequest(req, res) { + const { params, body } = parseReq(req, grantAccessRequestSchema) + const projectId = params.Project_id + const requesterId = params.user_id + const { privilegeLevel, notify } = body + const sessionUserId = SessionManager.getLoggedInUserId(req.session) + + // Capture the requester's current access and any pending request up front: + // the request tells us whether to notify (setCollaboratorPrivilegeLevel + // clears it as a side effect), and the current level lets us keep a repeat + // grant idempotent. + const projectAccess = + await CollaboratorsGetter.promises.getProjectAccess(projectId) + const currentPrivilegeLevel = projectAccess.privilegeLevelForUser(requesterId) + const hadRequest = Boolean(projectAccess.getAccessRequestForUser(requesterId)) + + // Granting editor/reviewer access consumes an edit-collaborator slot, so + // refuse if it would push the project over its collaborator limit — same + // guard the regular "change privilege level" flow uses. Skip it when the + // requester already holds the target level, so retrying a grant that + // already succeeded stays idempotent even at the limit. + if (currentPrivilegeLevel !== privilegeLevel) { + const allowed = + await LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel( + projectId, + requesterId, + privilegeLevel + ) + if (!allowed) { + return HttpErrorHandler.forbidden( + req, + res, + 'edit collaborator limit reached' + ) + } + } + + // A NotFoundError (requester is no longer a member) is translated to a 404 + // by the error-handling middleware. + await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel( + projectId, + requesterId, + privilegeLevel, + {}, + { + ipAddress: req.ip, + initiatorId: sessionUserId, + } + ) + + // The requester is now a named collaborator, so clear any pending email + // invite they may still have (e.g. they were invited by email but joined + // via a sharing link before requesting edit access). Mirrors the + // token-access join flow; a failure here shouldn't fail the grant. + try { + const requesterEmails = + await UserGetter.promises.getUserConfirmedEmails(requesterId) + await CollaboratorsInviteHandler.promises.revokeInviteForUser( + projectId, + requesterEmails + ) + } catch (err) { + logger.error( + { err, projectId, requesterId }, + 'failed to revoke pending invite after granting access request' + ) + } + + EditorRealTimeController.emitToRoom( + projectId, + 'project:collaboratorAccessLevel:changed', + { userId: requesterId } + ) + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', { + members: true, + invites: true, + accessRequests: true, + }) + + if (hadRequest) { + AnalyticsManager.recordEventForUserInBackground( + sessionUserId, + 'project-access-request-actioned', + { projectId, privilegeLevel, decision: 'accept' } + ) + if (notify) { + _notifyRequesterOfOutcome( + projectId, + requesterId, + 'granted', + privilegeLevel + ).catch(err => { + logger.error( + { err, projectId, requesterId, privilegeLevel }, + 'failed to notify requester of granted access request' + ) + }) + } + } + res.sendStatus(204) +} + +async function _notifyRequesterOfOutcome( + projectId, + requesterId, + outcome, + privilegeLevel +) { + const project = await ProjectGetter.promises.getProject(projectId, { + _id: 1, + name: 1, + }) + if (!project) return + const requester = await UserGetter.promises.getUser(requesterId, { + email: 1, + first_name: 1, + last_name: 1, + }) + if (!requester?.email) return + const template = + outcome === 'granted' ? 'accessRequestGranted' : 'accessRequestDeclined' + await EmailHandler.promises.sendEmail(template, { + to: requester.email, + project, + requester, + privilegeLevel, + }) +} + +async function _notifyOwnerOfAccessRequest( + projectId, + requesterId, + privilegeLevel +) { + const project = await ProjectGetter.promises.getProject(projectId, { + _id: 1, + name: 1, + owner_ref: 1, + }) + if (!project?.owner_ref) return + const users = await UserGetter.promises.getUsers( + [project.owner_ref, requesterId], + { + email: 1, + first_name: 1, + last_name: 1, + } + ) + const usersById = new Map(users.map(user => [user._id.toString(), user])) + const owner = usersById.get(project.owner_ref.toString()) + const requester = usersById.get(requesterId.toString()) + if (!owner?.email || !requester) return + await EmailHandler.promises.sendEmail('accessRequest', { + to: owner.email, + project, + owner, + requester, + privilegeLevel, + }) +} + async function _removeUserIdFromProject(projectId, userId) { await CollaboratorsHandler.promises.removeUserFromProject(projectId, userId) EditorRealTimeController.emitToRoom( diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs index aadd93db12..175a712ddb 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsGetter.mjs @@ -38,6 +38,13 @@ const { ObjectId } = mongodb * @property {boolean} [pendingReviewer] */ +/** + * @typedef AccessRequest + * @property {ObjectId} userId + * @property {PrivilegeLevel} privilegeLevel + * @property {Date} requestedAt + */ + // Wrapper for determining multiple dimensions of project access. class ProjectAccess { // Projection for the project fields the constructor needs. @@ -51,6 +58,7 @@ class ProjectAccess { pendingEditor_refs: 1, reviewer_refs: 1, pendingReviewer_refs: 1, + editAccessRequests: 1, } /** @type {ProjectMember[]} */ @@ -65,8 +73,11 @@ class ProjectAccess { /** @type {Record} */ #stats + /** @type {AccessRequest[]} */ + #accessRequests + /** - * @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: PublicAccessLevel; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; }} project + * @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: PublicAccessLevel; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; editAccessRequests?: AccessRequest[]; }} project */ constructor(project) { this.#members = _getMemberIdsWithPrivilegeLevelsFromFields( @@ -88,6 +99,7 @@ class ProjectAccess { } this.#publicAccessLevel = project.publicAccesLevel this.#ownerId = project.owner_ref + this.#accessRequests = project.editAccessRequests || [] } /** @@ -140,6 +152,90 @@ class ProjectAccess { return owner } + /** + * Fetch user details for every pending access request so the owner can + * see who has asked. Mirrors `_loadMembers` so the user lookup is + * batched. + * + * @return {Promise>} + */ + async loadAccessRequests() { + if (this.#accessRequests.length === 0) return [] + const userIds = Array.from( + new Set(this.#accessRequests.map(r => r.userId.toString())) + ) + const users = new Map() + for (const user of await UserGetter.promises.getUsers(userIds, { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + })) { + users.set(user._id.toString(), user) + } + return this.#accessRequests + .map(request => { + const user = users.get(request.userId.toString()) + if (!user) return null + return { + user, + privilegeLevel: request.privilegeLevel, + requestedAt: request.requestedAt, + } + }) + .filter(r => r != null) + } + + /** + * Owner-facing view of the pending requests: the flattened user details + * plus each requester's current privilege level (so the UI can tell + * whether granting would consume a new collaborator slot). Shared by the + * editor bootstrap and the access-requests endpoint. + * + * @return {Promise>} + */ + async loadAccessRequestsView() { + const loaded = await this.loadAccessRequests() + return loaded.map(r => ({ + _id: r.user._id, + email: r.user.email, + first_name: r.user.first_name, + last_name: r.user.last_name, + privilegeLevel: r.privilegeLevel, + currentPrivilegeLevel: this.privilegeLevelForUser(r.user._id), + requestedAt: r.requestedAt, + })) + } + + /** + * Look up a single user's pending request, if any. Cheap — no user + * lookup. Returned to non-owners so they can see whether they have an + * outstanding request of their own. + * + * @param {string | ObjectId} userId + * @return {{privilegeLevel: PrivilegeLevel, requestedAt: Date} | null} + */ + getAccessRequestForUser(userId) { + if (!userId) return null + const idString = userId.toString() + for (const request of this.#accessRequests) { + if (request.userId.toString() === idString) { + return { + privilegeLevel: request.privilegeLevel, + requestedAt: request.requestedAt, + } + } + } + return null + } + + /** + * @return {number} + */ + accessRequestCount() { + return this.#accessRequests.length + } + /** * @return {ProjectMember[]} */ diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.mjs index 73ad53508b..eab24ece97 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.mjs @@ -1,4 +1,5 @@ import { callbackify } from 'node:util' +import mongodb from 'mongodb-legacy' import OError from '@overleaf/o-error' import { Project } from '../../models/Project.mjs' import ProjectGetter from '../Project/ProjectGetter.mjs' @@ -14,6 +15,8 @@ import EditorRealTimeController from '../Editor/EditorRealTimeController.mjs' import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs' import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs' +const { ObjectId } = mongodb + export default { userIsTokenMember: callbackify(userIsTokenMember), removeUserFromProject: callbackify(removeUserFromProject), @@ -27,6 +30,8 @@ export default { addUserIdToProject, transferProjects, setCollaboratorPrivilegeLevel, + requestAccess, + declineAccessRequest, convertTrackChangesToExplicitFormat, }, } @@ -43,6 +48,7 @@ async function removeUserFromProject(projectId, userId) { reviewer_refs: userId, pendingEditor_refs: userId, pendingReviewer_refs: userId, + editAccessRequests: { userId }, tokenAccessReadOnly_refs: userId, tokenAccessReadAndWrite_refs: userId, archived: userId, @@ -263,6 +269,15 @@ async function transferProjects(fromUserId, toUserId) { } ).exec() + // Edit access requests are ephemeral signals; drop them on the source user + // rather than migrating them to the destination user. + await Project.updateMany( + { 'editAccessRequests.userId': fromUserId }, + { + $pull: { editAccessRequests: { userId: fromUserId } }, + } + ).exec() + // Flush in background, no need to block on this _flushProjects(projectIds).catch(err => { logger.err( @@ -280,14 +295,18 @@ async function setCollaboratorPrivilegeLevel( auditInfo = {} ) { AsyncLocalStorage.removeItem(`projectAccess:${projectId}`) - // Make sure we're only updating the project if the user is already a - // collaborator + // Make sure we're only updating the project if the user holds some form + // of access already — invited collaborator OR token-share viewer. + // Token-share entries can show up here when the owner promotes a + // pending access requester who reached the project via the link. const query = { _id: projectId, $or: [ { collaberator_refs: userId }, { readOnly_refs: userId }, { reviewer_refs: userId }, + { tokenAccessReadOnly_refs: userId }, + { tokenAccessReadAndWrite_refs: userId }, ], } let update @@ -300,6 +319,9 @@ async function setCollaboratorPrivilegeLevel( pendingEditor_refs: userId, reviewer_refs: userId, pendingReviewer_refs: userId, + editAccessRequests: { userId }, + tokenAccessReadOnly_refs: userId, + tokenAccessReadAndWrite_refs: userId, }, $addToSet: { collaberator_refs: userId }, } @@ -312,6 +334,9 @@ async function setCollaboratorPrivilegeLevel( pendingEditor_refs: userId, collaberator_refs: userId, pendingReviewer_refs: userId, + editAccessRequests: { userId }, + tokenAccessReadOnly_refs: userId, + tokenAccessReadAndWrite_refs: userId, }, $addToSet: { reviewer_refs: userId }, } @@ -335,7 +360,13 @@ async function setCollaboratorPrivilegeLevel( } case PrivilegeLevels.READ_ONLY: { update = { - $pull: { collaberator_refs: userId, reviewer_refs: userId }, + $pull: { + collaberator_refs: userId, + reviewer_refs: userId, + editAccessRequests: { userId }, + tokenAccessReadOnly_refs: userId, + tokenAccessReadAndWrite_refs: userId, + }, $addToSet: { readOnly_refs: userId }, } @@ -382,6 +413,85 @@ async function setCollaboratorPrivilegeLevel( } } +// Records a viewer (invited or token-share) or reviewer asking the owner for +// a higher privilege level. Authorization (the caller can read the project) +// is enforced by the `ensureUserCanReadProject` route middleware, and the +// controller checks that the requested level is one they may ask for. +// `isNew` is true only when this is the first request for that user — +// re-submissions (e.g. changing the requested level) return isNew=false so +// emails/analytics fire exactly once per request. +async function requestAccess(projectId, userId, privilegeLevel) { + if ( + privilegeLevel !== PrivilegeLevels.READ_AND_WRITE && + privilegeLevel !== PrivilegeLevels.REVIEW + ) { + throw new OError(`invalid requested privilege level: ${privilegeLevel}`) + } + AsyncLocalStorage.removeItem(`projectAccess:${projectId}`) + // Aggregation-pipeline updates are not cast by Mongoose, so normalise the + // id to an ObjectId ourselves — otherwise a session-string userId would be + // stored as a string and later `$pull`s (which use ObjectIds) wouldn't + // match, leaving the request behind after a grant/decline. + const userIdObjectId = new ObjectId(userId) + // Rebuild the array in a single atomic pipeline update: drop any existing + // entry for this user and append the new one. This can't race into a + // duplicate entry (it's one write) and doubles as the "update in place" + // path for re-submissions. `returnDocument: 'before'` lets us tell whether + // the user already had a request, so emails/analytics fire once. + const before = await Project.findOneAndUpdate( + { _id: projectId }, + [ + { + $set: { + editAccessRequests: { + $concatArrays: [ + { + $filter: { + input: { $ifNull: ['$editAccessRequests', []] }, + cond: { $ne: ['$$this.userId', userIdObjectId] }, + }, + }, + [ + { + userId: userIdObjectId, + privilegeLevel, + requestedAt: new Date(), + }, + ], + ], + }, + }, + }, + ], + { projection: { editAccessRequests: 1 }, returnDocument: 'before' } + ).exec() + if (!before) { + return { isNew: false } + } + const isNew = !before.editAccessRequests?.some( + request => request.userId.toString() === userId.toString() + ) + return { isNew } +} + +async function declineAccessRequest(projectId, userId) { + AsyncLocalStorage.removeItem(`projectAccess:${projectId}`) + // Pull the entry and report back the level that was requested (from the + // pre-image) so the caller can record it / notify the requester. + const before = await Project.findOneAndUpdate( + { _id: projectId }, + { $pull: { editAccessRequests: { userId } } }, + { projection: { editAccessRequests: 1 }, returnDocument: 'before' } + ).exec() + const removedRequest = before?.editAccessRequests?.find( + request => request.userId.toString() === userId.toString() + ) + return { + removed: Boolean(removedRequest), + privilegeLevel: removedRequest?.privilegeLevel, + } +} + async function userIsTokenMember(userId, projectId) { if (!userId) { return false diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs index bcc4bf92db..ad783a246c 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsRouter.mjs @@ -36,6 +36,10 @@ const rateLimiters = { points: 25, // just over view-project-invite duration: 60, }), + requestAccess: new RateLimiter('request-access', { + points: 10, + duration: 60 * 10, + }), } export default { @@ -68,6 +72,37 @@ export default { CollaboratorsController.getAllMembers ) + webRouter.post( + '/project/:Project_id/request-access', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.requestAccess, { + params: ['Project_id'], + }), + CollaboratorsController.requestAccess + ) + + webRouter.get( + '/project/:Project_id/access-requests', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + CollaboratorsController.getAccessRequests + ) + + webRouter.delete( + '/project/:Project_id/access-requests/:user_id', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + CollaboratorsController.declineAccessRequest + ) + + webRouter.post( + '/project/:Project_id/access-requests/:user_id/grant', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + CollaboratorsController.grantAccessRequest + ) + webRouter.post( '/project/:Project_id/transfer-ownership', AuthenticationController.requireLogin(), diff --git a/services/web/app/src/Features/Editor/EditorHttpController.mjs b/services/web/app/src/Features/Editor/EditorHttpController.mjs index d98968f417..0da6a4b8e7 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.mjs +++ b/services/web/app/src/Features/Editor/EditorHttpController.mjs @@ -111,13 +111,23 @@ async function _buildJoinProjectView(req, projectId, userId) { await projectAccess.loadOwnerAndInvitedMembers()) invites = await CollaboratorsInviteGetter.promises.getAllInvites(projectId) } + const accessRequestData = {} + if (privilegeLevel === PrivilegeLevels.OWNER) { + accessRequestData.editAccessRequests = + await projectAccess.loadAccessRequestsView() + } else if (userId) { + // Caller's own request only, so safe for restricted (link-share) viewers. + accessRequestData.myAccessRequest = + projectAccess.getAccessRequestForUser(userId) + } return { project: ProjectEditorHandler.buildProjectModelView( project, ownerMember, members, invites, - isRestrictedUser + isRestrictedUser, + accessRequestData ), privilegeLevel, isTokenMember, diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 0ce977fcac..919a0f7c8f 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/services/web/app/src/Features/Email/EmailBuilder.mjs @@ -768,6 +768,106 @@ templates.ownershipTransferConfirmationNewOwner = ctaTemplate({ }, }) +templates.accessRequest = ctaTemplate({ + subject(opts) { + const requester = _.escape( + _formatUserNameAndEmail(opts.requester, 'A collaborator') + ) + const role = opts.privilegeLevel === 'review' ? 'reviewer' : 'editor' + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'your project') + ) + return `${requester} requested ${role} access to ${projectName} - ${settings.appName}` + }, + title(opts) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'Your project') + ) + return `${projectName} - Access request` + }, + message(opts, isPlainText) { + const requester = _.escape( + _formatUserNameAndEmail(opts.requester, 'A collaborator') + ) + const role = opts.privilegeLevel === 'review' ? 'reviewer' : 'editor' + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'your project') + ) + const projectNameDisplay = isPlainText + ? projectName + : `${projectName}` + return [ + `${requester} has requested ${role} access to ${projectNameDisplay}.`, + `Open the share settings to grant access, or dismiss the request if you don’t want to share.`, + ] + }, + ctaText(opts) { + return 'Manage sharing' + }, + ctaURL(opts) { + return `${settings.siteUrl}/project/${opts.project._id.toString()}?share=1` + }, +}) + +templates.accessRequestGranted = ctaTemplate({ + subject(opts) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'a project') + ) + return `Your access request to ${projectName} was granted - ${settings.appName}` + }, + title(opts) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'A project') + ) + return `${projectName} - Access granted` + }, + message(opts, isPlainText) { + const role = opts.privilegeLevel === 'review' ? 'reviewer' : 'editor' + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'a project') + ) + const projectNameDisplay = isPlainText + ? projectName + : `${projectName}` + return [ + `Your access request to ${projectNameDisplay} was granted. You now have ${role} access.`, + ] + }, + ctaText(opts) { + return 'Open project' + }, + ctaURL(opts) { + return `${settings.siteUrl}/project/${opts.project._id.toString()}` + }, +}) + +templates.accessRequestDeclined = NoCTAEmailTemplate({ + subject(opts) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'a project') + ) + return `Your access request to ${projectName} was declined - ${settings.appName}` + }, + title(opts) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'A project') + ) + return `${projectName} - Access request declined` + }, + message(opts, isPlainText) { + const projectName = _.escape( + SpamSafe.safeProjectName(opts.project.name, 'a project') + ) + const projectNameDisplay = isPlainText + ? projectName + : `${projectName}` + return [ + `Your request for additional access to ${projectNameDisplay} was declined by the project owner. You still have your current access.`, + ] + }, +}) + templates.userOnboardingEmail = NoCTAEmailTemplate({ subject(opts) { return `Getting more out of ${settings.appName}` diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 62201b4dde..e09055a2f2 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -956,6 +956,16 @@ const _ProjectController = { await Modules.promises.hooks.fire('assignLabsSplitTests', req, res) } + // "Request edit access" is gated on the project owner's + // `sharing-updates` bucket so a viewer outside the experiment can + // still ask whenever the owner can act on it inside the new + // share modal. + const ownerHasSharingUpdates = + await SplitTestHandler.promises.featureFlagEnabledForUser( + project.owner_ref.toString(), + 'sharing-updates' + ) + res.render(template, { title: project.name, priority_title: true, @@ -1052,6 +1062,7 @@ const _ProjectController = { }, standardPlanPricing, recommendedCurrency, + ownerHasSharingUpdates, }) timer.done() } catch (err) { diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs index e6b0af442a..c85381431d 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.mjs +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.mjs @@ -10,7 +10,8 @@ export default ProjectEditorHandler = { ownerMember, members, invites, - isRestrictedUser + isRestrictedUser, + accessRequestData = {} ) { const result = { _id: project._id, @@ -40,6 +41,13 @@ export default ProjectEditorHandler = { result.invites = this.buildInvitesView(invites) } + if (accessRequestData.editAccessRequests !== undefined) { + result.editAccessRequests = accessRequestData.editAccessRequests + } + if (accessRequestData.myAccessRequest !== undefined) { + result.myAccessRequest = accessRequestData.myAccessRequest + } + result.features = _.defaults(ownerMember?.user?.features || {}, { collaborators: -1, // Infinite versioning: false, diff --git a/services/web/app/src/models/Project.mjs b/services/web/app/src/models/Project.mjs index ffa49dc6ed..0ac5683850 100644 --- a/services/web/app/src/models/Project.mjs +++ b/services/web/app/src/models/Project.mjs @@ -32,6 +32,13 @@ export const ProjectSchema = new Schema( readOnly_refs: [{ type: ObjectId, ref: 'User' }], pendingEditor_refs: [{ type: ObjectId, ref: 'User' }], pendingReviewer_refs: [{ type: ObjectId, ref: 'User' }], + editAccessRequests: [ + { + userId: { type: ObjectId, ref: 'User' }, + privilegeLevel: { type: String }, + requestedAt: { type: Date, default: () => new Date() }, + }, + ], rootDoc_id: { type: ObjectId }, rootFolder: [FolderSchema], mainBibliographyDoc_id: { type: ObjectId }, diff --git a/services/web/app/views/project/editor/_meta.pug b/services/web/app/views/project/editor/_meta.pug index f16c95c1c8..fb0a637c0e 100644 --- a/services/web/app/views/project/editor/_meta.pug +++ b/services/web/app/views/project/editor/_meta.pug @@ -8,6 +8,7 @@ meta(name="ol-anonymous" data-type="boolean" content=anonymous) meta(name="ol-brandVariation" data-type="json" content=brandVariation) meta(name="ol-isTokenMember" data-type="boolean" content=isTokenMember) meta(name="ol-isRestrictedTokenMember" data-type="boolean" content=isRestrictedTokenMember) +meta(name="ol-ownerHasSharingUpdates" data-type="boolean" content=ownerHasSharingUpdates) meta(name="ol-maxDocLength" data-type="number" content=maxDocLength) meta(name="ol-maxReconnectGracefullyIntervalMs" data-type="number" content=maxReconnectGracefullyIntervalMs) meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b462dd70cb..cfc4747930 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -49,7 +49,10 @@ "access_all_premium_features": "", "access_denied": "", "access_edit_your_projects": "", + "access_granted": "", "access_levels_changed": "", + "access_request_declined": "", + "access_requests": "", "access_updated": "", "account_associated_with_x": "", "account_billed_manually": "", @@ -429,6 +432,7 @@ "date_and_owner": "", "date_and_time": "", "dealing_with_errors": "", + "decline": "", "decrease_indent": "", "delete": "", "delete_account": "", @@ -572,6 +576,7 @@ "each_user_will_have_access_to": "", "easily_manage_your_project_files_everywhere": "", "edit": "", + "edit_access_requested": "", "edit_comment": "", "edit_comment_error_message": "", "edit_comment_error_title": "", @@ -1272,6 +1277,8 @@ "n_more_updates_above_plural": "", "n_more_updates_below": "", "n_more_updates_below_plural": "", + "n_people_requested_access": "", + "n_people_requested_access_plural": "", "name": "", "name_usage_explanation": "", "navigation": "", @@ -1326,6 +1333,7 @@ "no_pdf_error_reason_output_pdf_already_exists": "", "no_pdf_error_reason_unrecoverable_error": "", "no_pdf_error_title": "", + "no_pending_access_requests": "", "no_personal_access_tokens": "", "no_preview_available": "", "no_project_notifications_description": "", @@ -1686,6 +1694,7 @@ "repository_name": "", "repository_visibility": "", "republish": "", + "request_edit_access": "", "resend": "", "resend_confirmation_code": "", "resend_group_invite": "", @@ -1882,6 +1891,7 @@ "showing_x_results_plural": "", "sidebar": "", "sign_up": "", + "signed_in_as_email": "", "simple_search_mode": "", "single_sign_on_sso": "", "size": "", @@ -2438,6 +2448,7 @@ "were_performing_maintenance": "", "weve_converted_your_content_to_latex": "", "weve_hit_a_problem_try_starting_a_new_chat": "", + "what_access_do_you_want": "", "what_did_you_find_most_helpful": "", "what_do_you_need_help_with": "", "what_does_this_mean_for_you": "", diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/request-access-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/request-access-button.tsx new file mode 100644 index 0000000000..7290ed2c21 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/toolbar/request-access-button.tsx @@ -0,0 +1,68 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' +import { useProjectContext } from '@/shared/context/project-context' +import { useUserContext } from '@/shared/context/user-context' +import getMeta from '@/utils/meta' +import RequestAccessModal from './request-access-modal' + +export default function RequestAccessButton() { + const { t } = useTranslation() + // Gate on the project owner's split-test bucket rather than the + // viewer's: a viewer outside the experiment should still be able to + // ask whenever the owner can see/action the request in the new share + // modal. Computed server-side at editor load and surfaced as a meta + // tag, so it never changes for the life of the editor session. + const ownerHasSharingUpdates = getMeta('ol-ownerHasSharingUpdates') + const { permissionsLevel } = useIdeReactContext() + const { project } = useProjectContext() + const { id: userId } = useUserContext() + const [showModal, setShowModal] = useState(false) + const [locallyRequested, setLocallyRequested] = useState(false) + + const handleOpen = useCallback(() => setShowModal(true), []) + const handleClose = useCallback(() => setShowModal(false), []) + const handleSuccess = useCallback(() => { + setLocallyRequested(true) + setShowModal(false) + }, []) + + if (!ownerHasSharingUpdates) return null + if (permissionsLevel !== 'readOnly' && permissionsLevel !== 'review') + return null + if (!userId) return null + + // Once the owner grants *editor* access the requester becomes a named + // collaborator with nothing left to request, so hide the button as soon as + // they show up as an editor in the (live-refetched) members list — their + // own permissionsLevel only updates on reload. Reviewers are intentionally + // not hidden here: they can still request editor access. + const isNowEditor = project?.members?.some( + member => member._id === userId && member.privileges === 'readAndWrite' + ) + if (isNowEditor) return null + + const hasRequested = + locallyRequested || Boolean(project?.myAccessRequest?.privilegeLevel) + + return ( + <> +
+ + {hasRequested ? t('edit_access_requested') : t('request_edit_access')} + +
+ + + ) +} diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/request-access-modal.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/request-access-modal.tsx new file mode 100644 index 0000000000..62aa26dfee --- /dev/null +++ b/services/web/frontend/js/features/ide-react/components/toolbar/request-access-modal.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import Notification from '@/shared/components/notification' +import MaterialIcon from '@/shared/components/material-icon' +import DropdownMenuItem from '@/shared/components/dropdown/dropdown-menu-item' +import { + OLDropdown, + OLDropdownMenu, + OLDropdownToggle, +} from '@/shared/components/ol/ol-dropdown-menu' +import { requestAccess } from '@/features/share-project-modal/utils/api' +import { useProjectContext } from '@/shared/context/project-context' +import { useUserContext } from '@/shared/context/user-context' +import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' +import useAsync from '@/shared/hooks/use-async' +import { RequestedPrivilegeLevel } from '@/shared/context/types/project-metadata' + +// `as const` keeps the icon names as literals so they satisfy +// MaterialIcon's `type` prop when combined with `unfilled` (a widened +// `string` would resolve to the filled-icon overload and fail type-check). +const LEVEL_OPTIONS = [ + { key: 'readAndWrite', icon: 'edit' }, + { key: 'review', icon: 'mode_comment' }, +] as const satisfies ReadonlyArray<{ + key: RequestedPrivilegeLevel + icon: string +}> + +export default function RequestAccessModal({ + show, + onSuccess, + onCancel, +}: { + show: boolean + onSuccess: () => void + onCancel: () => void +}) { + const { t } = useTranslation() + const { projectId } = useProjectContext() + const { email } = useUserContext() + const { permissionsLevel } = useIdeReactContext() + const { isLoading, isError, runAsync } = useAsync() + + // Reviewers can only step up to editor; viewers can choose editor or + // reviewer. The requestable set always excludes the level the user + // already holds. + const options = + permissionsLevel === 'review' + ? LEVEL_OPTIONS.filter(o => o.key === 'readAndWrite') + : LEVEL_OPTIONS + + const [privilegeLevel, setPrivilegeLevel] = + useState('readAndWrite') + + // Built with static t() calls (not t(dynamicKey)) so the keys are picked up + // by the translation extractor and never render as raw key strings. + const levelLabels: Record = { + readAndWrite: t('editor'), + review: t('reviewer'), + } + + function submit() { + runAsync(requestAccess(projectId, privilegeLevel)) + .then(() => onSuccess()) + .catch(() => {}) + } + + return ( + + + {t('request_edit_access')} + + +

{t('signed_in_as_email', { email })}

+ + {t('what_access_do_you_want')} +
+ + eventKey && setPrivilegeLevel(eventKey) + } + > + + {levelLabels[privilegeLevel]} + + + + {options.map(option => ( + } + active={privilegeLevel === option.key} + trailingIcon={ + privilegeLevel === option.key ? 'check' : undefined + } + > + {levelLabels[option.key]} + + ))} + + +
+
+

+ {t( + 'your_name_and_email_address_will_be_visible_to_the_project_owner_and_other_editors' + )} +

+ {isError && ( + + )} +
+ +
{isLoading && }
+ + {t('cancel')} + + + {t('send_request')} + +
+
+ ) +} diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/share-project-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/share-project-button.tsx index bd026bba54..48a6a22aa8 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/share-project-button.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/share-project-button.tsx @@ -1,16 +1,25 @@ import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal' import OLButton from '@/shared/components/ol/ol-button' +import OLBadge from '@/shared/components/ol/ol-badge' import MaterialIcon from '@/shared/components/material-icon' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useEditorContext } from '@/shared/context/editor-context' +import { useProjectContext } from '@/shared/context/project-context' export default function ShareProjectButton() { const { t } = useTranslation() const { sendEventOnce } = useEditorAnalytics() + const { isProjectOwner } = useEditorContext() + const { project } = useProjectContext() const [showShareModal, setShowShareModal] = useState(false) + const accessRequestCount = isProjectOwner + ? (project?.editAccessRequests?.length ?? 0) + : 0 + const handleOpenShareModal = useCallback(() => { sendEventOnce('ide-open-share-modal-once') setShowShareModal(true) @@ -20,6 +29,22 @@ export default function ShareProjectButton() { setShowShareModal(false) }, []) + // Open the share modal automatically when the editor is loaded with + // ?share=1 (used by the "Manage sharing" CTA in the access-request + // email). Strip the flag so a reload doesn't reopen it. + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get('share') !== '1') return + setShowShareModal(true) + params.delete('share') + const search = params.toString() + const newUrl = + window.location.pathname + + (search ? `?${search}` : '') + + window.location.hash + window.history.replaceState(null, '', newUrl) + }, []) + return ( <>
@@ -30,6 +55,11 @@ export default function ShareProjectButton() { onClick={handleOpenShareModal} > {t('share')} + {accessRequestCount > 0 && ( + + {accessRequestCount} + + )}
{ )}
{!isOfflineDueToNetworkStall && } + {!isRestrictedTokenMember && } {shouldDisplaySubmitButton && cobranding && ( diff --git a/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts b/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts index 55d9f01ba9..f590f9b9c6 100644 --- a/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts +++ b/services/web/frontend/js/features/ide-react/hooks/use-socket-listeners.ts @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import useSocketListener from '@/features/ide-react/hooks/use-socket-listener' import { + listProjectAccessRequests, listProjectInvites, listProjectMembers, } from '@/features/share-project-modal/utils/api' @@ -76,7 +77,11 @@ function useSocketListeners() { socket, 'project:membership:changed', useCallback( - (data: { members?: boolean; invites?: boolean }) => { + (data: { + members?: boolean + invites?: boolean + accessRequests?: boolean + }) => { if (data.members) { listProjectMembers(projectId) .then(({ members }) => { @@ -89,6 +94,21 @@ function useSocketListeners() { }) } + // Access requests are admin-only, so only the owner refetches them; + // a requester's own `myAccessRequest` comes from the editor bootstrap. + if (data.accessRequests && permissionsLevel === 'owner') { + listProjectAccessRequests(projectId) + .then(({ editAccessRequests }) => { + updateProject({ editAccessRequests }) + }) + .catch(err => { + debugConsole.error( + 'Error fetching access requests for project', + err + ) + }) + } + if (data.invites && permissionsLevel === 'owner') { listProjectInvites(projectId) .then(({ invites }) => { diff --git a/services/web/frontend/js/features/share-project-modal/components/access-requests.tsx b/services/web/frontend/js/features/share-project-modal/components/access-requests.tsx new file mode 100644 index 0000000000..bcab15bbd9 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/access-requests.tsx @@ -0,0 +1,263 @@ +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row' +import MaterialIcon from '@/shared/components/material-icon' +import OLButton from '@/shared/components/ol/ol-button' +import DropdownMenuItem from '@/shared/components/dropdown/dropdown-menu-item' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { + OLDropdown, + OLDropdownMenu, + OLDropdownToggle, +} from '@/shared/components/ol/ol-dropdown-menu' +import { useProjectContext } from '@/shared/context/project-context' +import { useShareProjectContext } from './share-project-modal' +import { + declineAccessRequest, + grantAccessRequest, +} from '@/features/share-project-modal/utils/api' +import type { + EditAccessRequest, + ProjectMember, + RequestedPrivilegeLevel, +} from '@/shared/context/types/project-metadata' +import type { ShareModalScreen } from './share-project-modal-content' + +export default function AccessRequests({ + setScreen, + canAddCollaborators, +}: { + setScreen: React.Dispatch> + canAddCollaborators: boolean +}) { + const { t } = useTranslation() + const { projectId, project, updateProject, features } = useProjectContext() + const { monitorRequest, setSuccessActionMessage } = useShareProjectContext() + const editAccessRequests = useMemo( + () => project?.editAccessRequests ?? [], + [project] + ) + + if (editAccessRequests.length === 0) { + return ( + <> +

{t('access_requests')}

+

{t('no_pending_access_requests')}

+ + ) + } + + return ( + <> +

{t('access_requests')}

+ {editAccessRequests.map(request => ( + { + const remaining = editAccessRequests.filter( + r => r._id !== actedUserId + ) + updateProject({ editAccessRequests: remaining }) + if (remaining.length === 0) { + setScreen('project-access') + } + }} + projectId={projectId} + monitorRequest={monitorRequest} + setSuccessActionMessage={setSuccessActionMessage} + updateProject={updateProject} + currentMembers={project?.members ?? []} + canAddCollaborators={canAddCollaborators} + collaboratorLimit={features?.collaborators ?? 1} + /> + ))} + + ) +} + +function AccessRequestRow({ + request, + onActed, + projectId, + monitorRequest, + setSuccessActionMessage, + updateProject, + currentMembers, + canAddCollaborators, + collaboratorLimit, +}: { + request: EditAccessRequest + onActed: (userId: string) => void + projectId: string + monitorRequest: ReturnType['monitorRequest'] + setSuccessActionMessage: (msg: string) => void + updateProject: ReturnType['updateProject'] + currentMembers: ProjectMember[] + canAddCollaborators: boolean + collaboratorLimit: number +}) { + const { t } = useTranslation() + // Built with static t() calls so the keys are extracted (never t(dynamicKey)). + const privilegeLabels: Record = { + readAndWrite: t('editor'), + review: t('reviewer'), + } + const [privilege, setPrivilege] = useState( + request.privilegeLevel + ) + const [inflight, setInflight] = useState(false) + + // Granting only consumes a new edit-collaborator slot if the requester + // isn't already an editor/reviewer (e.g. a reviewer asking for editor + // stays within the same slot). When a new slot is needed and the project + // is at its limit, block the grant — mirrors the backend guard. + const needsNewSlot = + request.currentPrivilegeLevel !== 'readAndWrite' && + request.currentPrivilegeLevel !== 'review' + const limitReached = needsNewSlot && !canAddCollaborators + + const handleDecline = useCallback(() => { + setInflight(true) + // The notify toggle was removed for now; always notify the requester of + // the outcome. + monitorRequest(() => declineAccessRequest(projectId, request._id, true)) + .then(() => { + setSuccessActionMessage(t('access_request_declined')) + onActed(request._id) + }) + .catch(() => setInflight(false)) + }, [ + monitorRequest, + onActed, + projectId, + request._id, + setSuccessActionMessage, + t, + ]) + + const handleShare = useCallback(() => { + setInflight(true) + monitorRequest(() => + grantAccessRequest(projectId, request._id, privilege, true) + ) + .then(() => { + if (!currentMembers.some(m => m._id === request._id)) { + const stub: ProjectMember = { + _id: request._id, + email: request.email, + first_name: request.first_name, + last_name: request.last_name, + privileges: privilege, + } + updateProject({ members: [...currentMembers, stub] }) + } + setSuccessActionMessage(t('access_granted')) + onActed(request._id) + }) + .catch(() => setInflight(false)) + }, [ + currentMembers, + monitorRequest, + onActed, + privilege, + projectId, + request._id, + request.email, + request.first_name, + request.last_name, + setSuccessActionMessage, + t, + updateProject, + ]) + + return ( + +
+
+
+ +
{request.email}
+
+ + eventKey && setPrivilege(eventKey) + } + > + + {privilegeLabels[privilege]} + + + + } + active={privilege === 'readAndWrite'} + trailingIcon={ + privilege === 'readAndWrite' ? 'check' : undefined + } + > + {t('editor')} + + } + active={privilege === 'review'} + trailingIcon={privilege === 'review' ? 'check' : undefined} + > + {t('reviewer')} + + + +
+
+
+ {inflight && } + + {t('decline')} + + {limitReached ? ( + + {/* wrapping element so the tooltip still fires while the + button is disabled */} +
+ + {t('share')} + +
+
+ ) : ( + + {t('share')} + + )} +
+
+
+
+ ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx index 7ea3f95ee3..e5d9222f5d 100644 --- a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx @@ -13,8 +13,10 @@ import { } from '@/shared/components/ol/ol-dropdown-menu' import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item' import LinkSharing from '@/features/share-project-modal/components/link-sharing' +import Notification from '@/shared/components/notification' import { useEditorContext } from '@/shared/context/editor-context' import { useProjectContext } from '@/shared/context/project-context' +import type { ShareModalScreen } from '@/features/share-project-modal/components/share-project-modal-content' import { SharingLinkData, SharingLinkPrivileges, @@ -34,7 +36,7 @@ import { sendMB } from '@/infrastructure/event-tracking' import { debugConsole } from '@/utils/debugging' type ProjectAccessProps = { - setIsInvitedPeopleScreen: React.Dispatch> + setScreen: React.Dispatch> invitedPeopleCount: number } @@ -43,10 +45,7 @@ export type PendingAccessType = ExcludeStrict< 'legacyLinkSharing' > -function ProjectAccess({ - setIsInvitedPeopleScreen, - invitedPeopleCount, -}: ProjectAccessProps) { +function ProjectAccess({ setScreen, invitedPeopleCount }: ProjectAccessProps) { const { t } = useTranslation() const [pendingAccess, setPendingAccess] = useState( null @@ -67,7 +66,10 @@ function ProjectAccess({ sharingLinkData, setSharingLinkData, } = useShareProjectContext() - const { projectId } = useProjectContext() + const { projectId, project } = useProjectContext() + const accessRequestCount = isProjectOwner + ? (project?.editAccessRequests?.length ?? 0) + : 0 const privileges = sharingLinkData?.privileges @@ -211,6 +213,24 @@ function ProjectAccess({ return ( <> + {accessRequestCount > 0 && ( + setScreen('access-requests')} + > + {t('review')} + + } + /> + )}

{t('project_access')}

@@ -224,7 +244,7 @@ function ProjectAccess({ setIsInvitedPeopleScreen(true)} + onClick={() => setScreen('invited-people')} > {t('manage_access')} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx index c435405dcd..41c15f466c 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx @@ -15,16 +15,19 @@ import Notification from '@/shared/components/notification' import ErrorMessage from '@/features/share-project-modal/components/error-message' import ProjectAccess from '@/features/share-project-modal/components/project-access' import InvitedPeople from '@/features/share-project-modal/components/invited-people' +import AccessRequests from '@/features/share-project-modal/components/access-requests' +import type { ProjectMember } from '@/shared/context/types/project-metadata' +import type { ShareModalScreen } from './share-project-modal-content' type ShareModalBodyProps = { - isInvitedPeopleScreen: boolean - setIsInvitedPeopleScreen: React.Dispatch> + screen: ShareModalScreen + setScreen: React.Dispatch> error?: string } export default function ShareModalBody({ - isInvitedPeopleScreen, - setIsInvitedPeopleScreen, + screen, + setScreen, error, }: ShareModalBodyProps) { const { project, features } = useProjectContext() @@ -134,20 +137,16 @@ export default function ShareModalBody({ />
)} - {isInvitedPeopleScreen || !isProjectOwner ? ( - - ) : ( - - )} + ) : ( <> @@ -189,3 +188,57 @@ export default function ShareModalBody({ ) } + +type ShareModalScreenContentProps = { + screen: ShareModalScreen + setScreen: React.Dispatch> + isProjectOwner: boolean + sortedMembers: ProjectMember[] + invites?: ProjectMember[] + canAddCollaborators: boolean + hasExceededCollaboratorLimit: boolean + hasTrackChangesFeature: boolean +} + +// Picks the screen to render inside the new share modal. Non-owners only ever +// see the "invited people" view. +function ShareModalScreenContent({ + screen, + setScreen, + isProjectOwner, + sortedMembers, + invites, + canAddCollaborators, + hasExceededCollaboratorLimit, + hasTrackChangesFeature, +}: ShareModalScreenContentProps) { + const effectiveScreen = isProjectOwner ? screen : 'invited-people' + + if (effectiveScreen === 'access-requests') { + return ( + + ) + } + + if (effectiveScreen === 'invited-people') { + return ( + + ) + } + + return ( + + ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx index b724b05f82..3f9003b10c 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx @@ -32,6 +32,11 @@ const ReadOnlyTokenLink = lazy(() => const ShareModalBody = lazy(() => import('./share-modal-body')) +export type ShareModalScreen = + | 'project-access' + | 'invited-people' + | 'access-requests' + type ShareProjectModalContentProps = { cancel: () => void onShow: () => void @@ -78,18 +83,19 @@ function ShareProjectModalContentInner({ // token links are copied from their own inputs, so hide it when the new-link // feature is off. const isNewLinkEnabled = useFeatureFlag('sharing-updates-new-link') - const [isInvitedPeopleScreen, setIsInvitedPeopleScreen] = useState(false) const { successActionMessage, projectAccess } = useShareProjectContext() + const [screen, setScreen] = useState('project-access') const { isRestrictedTokenMember, isProjectOwner } = useEditorContext() + const isSubScreen = screen !== 'project-access' return ( <>
- {isSharingUpdatesEnabled && isInvitedPeopleScreen ? ( + {isSharingUpdatesEnabled && isSubScreen ? ( setIsInvitedPeopleScreen(false)} + onClick={() => setScreen('project-access')} leadingIcon="arrow_back_ios_new" > {t('back')} @@ -119,8 +125,8 @@ function ShareProjectModalContentInner({ ) : ( )} @@ -141,7 +147,7 @@ function ShareProjectModalContentInner({ {isSharingUpdatesEnabled ? ( <> {isNewLinkEnabled && - !isInvitedPeopleScreen && + !isSubScreen && projectAccess && (projectAccess === 'onlyInvitedPeople' || projectAccess.startsWith('anyoneInXyzWithTheLink') || diff --git a/services/web/frontend/js/features/share-project-modal/utils/api.ts b/services/web/frontend/js/features/share-project-modal/utils/api.ts index a46098be36..fc60356190 100644 --- a/services/web/frontend/js/features/share-project-modal/utils/api.ts +++ b/services/web/frontend/js/features/share-project-modal/utils/api.ts @@ -1,4 +1,7 @@ -import { ProjectMember } from '@/shared/context/types/project-metadata' +import { + ProjectMember, + RequestedPrivilegeLevel, +} from '@/shared/context/types/project-metadata' import { deleteJSON, getJSON, @@ -79,6 +82,36 @@ export function removeMemberFromProject( return deleteJSON(`/project/${projectId}/users/${member._id}`) } +export function requestAccess( + projectId: string, + privilegeLevel: RequestedPrivilegeLevel +) { + return postJSON(`/project/${projectId}/request-access`, { + body: { privilegeLevel }, + }) +} + +export function declineAccessRequest( + projectId: string, + userId: string, + notify: boolean +) { + return deleteJSON(`/project/${projectId}/access-requests/${userId}`, { + body: { notify }, + }) +} + +export function grantAccessRequest( + projectId: string, + userId: string, + privilegeLevel: RequestedPrivilegeLevel, + notify: boolean +) { + return postJSON(`/project/${projectId}/access-requests/${userId}/grant`, { + body: { privilegeLevel, notify }, + }) +} + export function transferProjectOwnership( projectId: string, member: ProjectMember @@ -103,6 +136,10 @@ export function listProjectMembers(projectId: string) { return getJSON(`/project/${projectId}/members`) } +export function listProjectAccessRequests(projectId: string) { + return getJSON(`/project/${projectId}/access-requests`) +} + export function listProjectInvites(projectId: string) { return getJSON(`/project/${projectId}/invites`) } diff --git a/services/web/frontend/js/shared/context/types/project-metadata.tsx b/services/web/frontend/js/shared/context/types/project-metadata.tsx index b4703a3e6d..f559b12948 100644 --- a/services/web/frontend/js/shared/context/types/project-metadata.tsx +++ b/services/web/frontend/js/shared/context/types/project-metadata.tsx @@ -2,6 +2,7 @@ import { UserId } from '../../../../../types/user' import { PublicAccessLevel } from '../../../../../types/public-access-level' import { ProjectSettings } from '@/features/ide-settings/utils/api' import { Folder } from '../../../../../types/folder' +import { ExtractStrict } from '../../../../../types/utils' export type ProjectMember = { _id: UserId @@ -22,11 +23,38 @@ export type ProjectOwner = { signUpDate: string } +// The privilege levels a viewer/reviewer may request (never 'readOnly' or +// 'owner'). Derived from the collaborator union so the literals aren't +// duplicated across the request-access UI and API helpers. +export type RequestedPrivilegeLevel = ExtractStrict< + ProjectMember['privileges'], + 'readAndWrite' | 'review' +> + +export type EditAccessRequest = { + _id: UserId + email: string + first_name: string + last_name: string + privilegeLevel: RequestedPrivilegeLevel + // the requester's *current* privilege on the project, so the owner UI can + // tell whether granting would consume a new collaborator slot + currentPrivilegeLevel: ProjectMember['privileges'] + requestedAt: string +} + +export type MyAccessRequest = Pick< + EditAccessRequest, + 'privilegeLevel' | 'requestedAt' +> | null + export interface ProjectMetadata extends ProjectSettings { _id: string mainBibliographyDocId?: string members: ProjectMember[] invites: ProjectMember[] + editAccessRequests?: EditAccessRequest[] + myAccessRequest?: MyAccessRequest features: { collaborators?: number compileGroup?: 'alpha' | 'standard' | 'priority' diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 7524b80fdc..7bda0d5dc0 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -237,6 +237,7 @@ export interface Meta { 'ol-odcData': OnboardingFormData 'ol-otMigrationStage': number 'ol-overallThemes': OverallThemeMeta[] + 'ol-ownerHasSharingUpdates': boolean 'ol-ownerIsManaged': boolean 'ol-pages': number 'ol-passwordStrengthOptions': PasswordStrengthOptions diff --git a/services/web/locales/en.json b/services/web/locales/en.json index d58c8cce39..440d3e0d1e 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -54,7 +54,10 @@ "access_all_premium_features": "Access all premium features, including more collaborators, more compile time, and higher AI allowance.", "access_denied": "Access Denied", "access_edit_your_projects": "Access and edit your projects", + "access_granted": "Access granted", "access_levels_changed": "Access levels changed", + "access_request_declined": "Access request declined", + "access_requests": "Access requests", "access_updated": "Access updated.", "account": "Account", "account_already_managed": "Your account is already managed.", @@ -575,6 +578,7 @@ "de": "German", "dealing_with_errors": "Dealing with errors", "december": "December", + "decline": "Decline", "decrease_indent": "Decrease indentation", "dedicated_account_manager": "Dedicated account manager", "default": "Default", @@ -742,6 +746,7 @@ "each_user_will_have_access_to": "Each user will have access to", "easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere", "edit": "Edit", + "edit_access_requested": "Edit access requested", "edit_comment": "Edit comment", "edit_comment_error_message": "There was an error editing the comment. Please try again in a few moments.", "edit_comment_error_title": "Edit Comment Error", @@ -1653,6 +1658,8 @@ "n_more_updates_above_plural": "__count__ more updates above", "n_more_updates_below": "__count__ more update below", "n_more_updates_below_plural": "__count__ more updates below", + "n_people_requested_access": "__count__ person requested access", + "n_people_requested_access_plural": "__count__ people requested access", "n_users": "__count__ user", "n_users_plural": "__count__ users", "name": "Name", @@ -1718,6 +1725,7 @@ "no_pdf_error_reason_output_pdf_already_exists": "This project contains a file called output.pdf. If that file exists, please rename it and compile again.", "no_pdf_error_reason_unrecoverable_error": "There is an unrecoverable LaTeX error. If there are LaTeX errors shown below or in the raw logs, please try to fix them and compile again.", "no_pdf_error_title": "No PDF", + "no_pending_access_requests": "No pending access requests", "no_personal_access_tokens": "This user has no personal access tokens", "no_planned_maintenance": "There is currently no planned maintenance", "no_preview_available": "Sorry, no preview is available.", @@ -2191,6 +2199,7 @@ "repository_name": "Repository Name", "repository_visibility": "Repository visibility", "republish": "Republish", + "request_edit_access": "Request edit access", "request_new_password_reset_email": "Request a new password reset email", "request_overleaf_common": "Request Overleaf Commons", "request_password_reset_to_reconfirm": "Request password reset email to reconfirm", @@ -2429,6 +2438,7 @@ "sign_up": "Sign up", "sign_up_for_free": "Sign up for free", "sign_up_for_free_account": "Sign up for a free account and receive regular updates", + "signed_in_as_email": "Signed in as __email__", "simple_pricing_for_individuals_and_teams": "Simple pricing for individuals and teams", "simple_search_mode": "Simple search", "single_sign_on_sso": "Single Sign-On (SSO)", @@ -3087,6 +3097,7 @@ "were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.", "weve_converted_your_content_to_latex": "We’ve converted your content to LaTeX. You may need to edit styling, particularly for tables, figures and citations.", "weve_hit_a_problem_try_starting_a_new_chat": "We’ve hit a problem. Try starting a new chat.", + "what_access_do_you_want": "What access do you need?", "what_did_you_find_most_helpful": "What did you find most helpful?", "what_do_you_need": "What do you need?", "what_do_you_need_help_with": "What do you need help with?", diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs index 7446128fa6..0a649eb1d9 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsController.test.mjs @@ -27,12 +27,43 @@ describe('CollaboratorsController', function () { promises: { removeUserFromProject: sinon.stub().resolves(), setCollaboratorPrivilegeLevel: sinon.stub().resolves(), + requestAccess: sinon.stub().resolves({ isNew: true }), + declineAccessRequest: sinon + .stub() + .resolves({ removed: true, privilegeLevel: 'review' }), }, createTokenHashPrefix: sinon.stub().returns('abc123'), } + ctx.CollaboratorsInviteHandler = { + promises: { + revokeInviteForUser: sinon.stub().resolves(), + }, + } + ctx.projectAccess = { + getOwnerId: sinon.stub().returns(ctx.user._id), + accessRequestCount: sinon.stub().returns(0), + loadAccessRequests: sinon.stub().resolves([]), + loadAccessRequestsView: sinon.stub().resolves([]), + getAccessRequestForUser: sinon.stub().returns(null), + privilegeLevelForUser: sinon.stub().returns('readOnly'), + } ctx.CollaboratorsGetter = { promises: { getAllInvitedMembers: sinon.stub(), + getProjectAccess: sinon.stub().resolves(ctx.projectAccess), + }, + } + ctx.AuthorizationManager = { + promises: { + getPrivilegeLevelForProject: sinon.stub().resolves('readOnly'), + }, + } + ctx.AnalyticsManager = { + recordEventForUserInBackground: sinon.stub(), + } + ctx.EmailHandler = { + promises: { + sendEmail: sinon.stub().resolves(), }, } ctx.EditorRealTimeController = { @@ -56,6 +87,10 @@ describe('CollaboratorsController', function () { promises: { getAllInvitedMembers: sinon.stub(), getUser: sinon.stub().resolves(ctx.user), + getUsers: sinon.stub().resolves([]), + getUserConfirmedEmails: sinon + .stub() + .resolves([{ email: 'req@example.com' }]), }, } ctx.OwnershipTransferHandler = { @@ -108,6 +143,13 @@ describe('CollaboratorsController', function () { }) ) + vi.doMock( + '../../../../app/src/Features/Collaborators/CollaboratorsInviteHandler.mjs', + () => ({ + default: ctx.CollaboratorsInviteHandler, + }) + ) + vi.doMock( '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs', () => ({ @@ -176,6 +218,24 @@ describe('CollaboratorsController', function () { }) ) + vi.doMock( + '../../../../app/src/Features/Authorization/AuthorizationManager.mjs', + () => ({ + default: ctx.AuthorizationManager, + }) + ) + + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager.mjs', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + + vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({ + default: ctx.EmailHandler, + })) + ctx.CollaboratorsController = (await import(MODULE_PATH)).default }) @@ -349,6 +409,40 @@ describe('CollaboratorsController', function () { }) }) + describe('getAccessRequests', function () { + beforeEach(async function (ctx) { + ctx.requesterId = new ObjectId() + ctx.editAccessRequests = [ + { + _id: ctx.requesterId, + email: 'r@e.com', + first_name: 'Req', + last_name: 'User', + privilegeLevel: 'readAndWrite', + currentPrivilegeLevel: 'readOnly', + requestedAt: new Date(), + }, + ] + ctx.projectAccess.loadAccessRequestsView = sinon + .stub() + .resolves(ctx.editAccessRequests) + await new Promise(resolve => { + ctx.req.params = { Project_id: ctx.projectId.toString() } + ctx.res.json = sinon.spy(() => resolve()) + ctx.CollaboratorsController.getAccessRequests(ctx.req, ctx.res) + }) + }) + + it('responds with the flattened owner view of the requests', function (ctx) { + expect( + ctx.CollaboratorsGetter.promises.getProjectAccess + ).to.have.been.calledWith(ctx.projectId.toString()) + expect(ctx.res.json).to.have.been.calledWith({ + editAccessRequests: ctx.editAccessRequests, + }) + }) + }) + describe('setCollaboratorInfo', function () { beforeEach(function (ctx) { ctx.req.params = { @@ -551,4 +645,365 @@ describe('CollaboratorsController', function () { }) }) }) + + describe('requestAccess', function () { + beforeEach(function (ctx) { + ctx.ownerId = new ObjectId() + ctx.req.params = { Project_id: ctx.projectId.toString() } + ctx.req.body = { privilegeLevel: 'readAndWrite' } + ctx.ProjectGetter.promises.getProject.resolves({ + _id: ctx.projectId, + name: 'My Project', + owner_ref: ctx.ownerId, + }) + ctx.UserGetter.promises.getUsers.resolves([ + { + _id: ctx.ownerId, + email: 'owner@example.com', + first_name: 'O', + last_name: 'wner', + }, + { + _id: ctx.user._id, + email: 'req@example.com', + first_name: 'Re', + last_name: 'Quester', + }, + ]) + }) + + it('rejects callers who are editors or owners', async function (ctx) { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + 'readAndWrite' + ) + await new Promise(resolve => { + ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve()) + ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res) + }) + expect(ctx.CollaboratorsHandler.promises.requestAccess).to.not.have.been + .called + }) + + it('lets a reviewer request editor access', async function (ctx) { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + 'review' + ) + ctx.req.body = { privilegeLevel: 'readAndWrite' } + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res) + }) + expect( + ctx.CollaboratorsHandler.promises.requestAccess + ).to.have.been.calledWith( + ctx.projectId.toString(), + ctx.user._id, + 'readAndWrite' + ) + }) + + it('rejects a reviewer asking for the reviewer level they already hold', async function (ctx) { + ctx.AuthorizationManager.promises.getPrivilegeLevelForProject.resolves( + 'review' + ) + ctx.req.body = { privilegeLevel: 'review' } + await new Promise(resolve => { + ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve()) + ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res) + }) + expect(ctx.CollaboratorsHandler.promises.requestAccess).to.not.have.been + .called + }) + + it('emits + emails + records analytics on a fresh request', async function (ctx) { + ctx.CollaboratorsHandler.promises.requestAccess.resolves({ + isNew: true, + }) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res) + }) + ctx.res.sendStatus.calledWith(204).should.equal(true) + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId.toString(), + 'project:membership:changed', + { accessRequests: true } + ) + expect( + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith(ctx.user._id, 'project-access-requested', { + projectId: ctx.projectId.toString(), + currentPrivilegeLevel: 'readOnly', + privilegeLevel: 'readAndWrite', + }) + // notify is async + swallowed via .catch; let microtasks drain + await new Promise(r => setImmediate(r)) + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( + 'accessRequest', + sinon.match({ to: 'owner@example.com', privilegeLevel: 'readAndWrite' }) + ) + }) + + it('skips email + analytics when isNew is false', async function (ctx) { + ctx.CollaboratorsHandler.promises.requestAccess.resolves({ + isNew: false, + }) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.requestAccess(ctx.req, ctx.res) + }) + expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called + expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have + .been.called + }) + }) + + describe('declineAccessRequest', function () { + beforeEach(function (ctx) { + ctx.requesterId = new ObjectId() + ctx.req.params = { + Project_id: ctx.projectId.toString(), + user_id: ctx.requesterId.toString(), + } + ctx.req.body = {} + ctx.ProjectGetter.promises.getProject.resolves({ + _id: ctx.projectId, + name: 'My Project', + }) + ctx.UserGetter.promises.getUser.resolves({ + email: 'req@example.com', + }) + }) + + it('pulls the entry, emits, and skips email when notify is absent', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.CollaboratorsHandler.promises.declineAccessRequest + ).to.have.been.calledWith( + ctx.projectId.toString(), + ctx.requesterId.toString() + ) + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId.toString(), + 'project:membership:changed', + { accessRequests: true } + ) + expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called + }) + + it('records a deny analytics event regardless of notify', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith( + ctx.user._id, + 'project-access-request-actioned', + { + projectId: ctx.projectId.toString(), + privilegeLevel: 'review', + decision: 'deny', + } + ) + }) + + it('does not record an analytics event when nothing was pulled', async function (ctx) { + ctx.CollaboratorsHandler.promises.declineAccessRequest.resolves({ + removed: false, + }) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have + .been.called + }) + + it('sends the declined email when notify=true and something was removed', async function (ctx) { + ctx.req.body = { notify: true } + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res) + }) + await new Promise(r => setImmediate(r)) + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( + 'accessRequestDeclined', + sinon.match({ to: 'req@example.com' }) + ) + }) + + it('skips the declined email when nothing was actually pulled', async function (ctx) { + ctx.req.body = { notify: true } + ctx.CollaboratorsHandler.promises.declineAccessRequest.resolves({ + removed: false, + }) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.declineAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called + }) + }) + + describe('grantAccessRequest', function () { + beforeEach(function (ctx) { + ctx.requesterId = new ObjectId() + ctx.req.params = { + Project_id: ctx.projectId.toString(), + user_id: ctx.requesterId.toString(), + } + ctx.req.body = { privilegeLevel: 'readAndWrite', notify: true } + ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns({ + privilegeLevel: 'readAndWrite', + requestedAt: new Date(), + }) + ctx.ProjectGetter.promises.getProject.resolves({ + _id: ctx.projectId, + name: 'My Project', + }) + ctx.UserGetter.promises.getUser.resolves({ + email: 'req@example.com', + }) + }) + + it('refuses to grant when it would exceed the collaborator limit', async function (ctx) { + ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves( + false + ) + await new Promise(resolve => { + ctx.HttpErrorHandler.forbidden = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel).to + .not.have.been.called + expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have + .been.called + }) + + it('skips the limit check when the requester already holds the target level', async function (ctx) { + // idempotent re-grant: already at the requested level, so granting must + // not be blocked even when the project is at its collaborator limit + ctx.projectAccess.privilegeLevelForUser = sinon + .stub() + .returns('readAndWrite') + ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel.resolves( + false + ) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.LimitationsManager.promises.canChangeCollaboratorPrivilegeLevel + ).to.not.have.been.called + expect( + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.have.been.calledWith( + ctx.projectId.toString(), + ctx.requesterId.toString(), + 'readAndWrite' + ) + }) + + it('promotes via setCollaboratorPrivilegeLevel and notifies', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel + ).to.have.been.calledWith( + ctx.projectId.toString(), + ctx.requesterId.toString(), + 'readAndWrite' + ) + expect(ctx.EditorRealTimeController.emitToRoom).to.have.been.calledWith( + ctx.projectId.toString(), + 'project:membership:changed', + { members: true, invites: true, accessRequests: true } + ) + await new Promise(r => setImmediate(r)) + expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith( + 'accessRequestGranted', + sinon.match({ to: 'req@example.com', privilegeLevel: 'readAndWrite' }) + ) + }) + + it('clears any pending invite for the granted user', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.UserGetter.promises.getUserConfirmedEmails + ).to.have.been.calledWith(ctx.requesterId.toString()) + expect( + ctx.CollaboratorsInviteHandler.promises.revokeInviteForUser + ).to.have.been.calledWith(ctx.projectId.toString(), [ + { email: 'req@example.com' }, + ]) + }) + + it('records an accept analytics event with the granted level', async function (ctx) { + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect( + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith( + ctx.user._id, + 'project-access-request-actioned', + { + projectId: ctx.projectId.toString(), + privilegeLevel: 'readAndWrite', + decision: 'accept', + } + ) + }) + + it('skips the granted email when there was no pending request', async function (ctx) { + ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns(null) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called + }) + + it('does not record an analytics event when there was no pending request', async function (ctx) { + ctx.projectAccess.getAccessRequestForUser = sinon.stub().returns(null) + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.AnalyticsManager.recordEventForUserInBackground).to.not.have + .been.called + }) + + it('still records the accept event when notify=false', async function (ctx) { + ctx.req.body = { privilegeLevel: 'review', notify: false } + await new Promise(resolve => { + ctx.res.sendStatus = sinon.spy(() => resolve()) + ctx.CollaboratorsController.grantAccessRequest(ctx.req, ctx.res) + }) + expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called + expect( + ctx.AnalyticsManager.recordEventForUserInBackground + ).to.have.been.calledWith( + ctx.user._id, + 'project-access-request-actioned', + { + projectId: ctx.projectId.toString(), + privilegeLevel: 'review', + decision: 'accept', + } + ) + }) + }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs index 2af1f1fbdb..b21f270a41 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsGetter.test.mjs @@ -641,4 +641,149 @@ describe('CollaboratorsGetter', function () { }) }) }) + + describe('ProjectAccess access-request helpers', function () { + beforeEach(function (ctx) { + ctx.requestedAt = new Date() + ctx.requesterA = new ObjectId() + ctx.requesterB = new ObjectId() + ctx.projectWithRequests = { + ...ctx.project, + owner_ref: ctx.ownerRef, + editAccessRequests: [ + { + userId: ctx.requesterA, + privilegeLevel: 'readAndWrite', + requestedAt: ctx.requestedAt, + }, + { + userId: ctx.requesterB, + privilegeLevel: 'review', + requestedAt: ctx.requestedAt, + }, + ], + } + }) + + it('returns the count of pending requests', function (ctx) { + const access = new ctx.CollaboratorsGetter.ProjectAccess( + ctx.projectWithRequests + ) + expect(access.accessRequestCount()).to.equal(2) + }) + + it('treats an empty/missing array as zero requests', function (ctx) { + const access = new ctx.CollaboratorsGetter.ProjectAccess({ + ...ctx.project, + owner_ref: ctx.ownerRef, + }) + expect(access.accessRequestCount()).to.equal(0) + expect(access.getAccessRequestForUser(ctx.requesterA)).to.be.null + }) + + it('looks up a single request without firing a user fetch', function (ctx) { + const access = new ctx.CollaboratorsGetter.ProjectAccess( + ctx.projectWithRequests + ) + const result = access.getAccessRequestForUser(ctx.requesterB) + expect(result).to.deep.equal({ + privilegeLevel: 'review', + requestedAt: ctx.requestedAt, + }) + expect(ctx.UserGetter.promises.getUsers).to.not.have.been.called + }) + + it('returns null when the user has no pending request', function (ctx) { + const access = new ctx.CollaboratorsGetter.ProjectAccess( + ctx.projectWithRequests + ) + expect(access.getAccessRequestForUser(ctx.nonMemberRef)).to.be.null + expect(access.getAccessRequestForUser(null)).to.be.null + }) + + it('batches user lookups and threads privilege/requestedAt through', async function (ctx) { + const userA = { + _id: ctx.requesterA, + email: 'a@example.com', + first_name: 'A', + last_name: 'One', + } + const userB = { + _id: ctx.requesterB, + email: 'b@example.com', + first_name: 'B', + last_name: 'Two', + } + ctx.UserGetter.promises.getUsers.resolves([userA, userB]) + const access = new ctx.CollaboratorsGetter.ProjectAccess( + ctx.projectWithRequests + ) + const loaded = await access.loadAccessRequests() + expect(ctx.UserGetter.promises.getUsers).to.have.been.calledOnce + expect(loaded).to.have.lengthOf(2) + expect(loaded[0]).to.deep.equal({ + user: userA, + privilegeLevel: 'readAndWrite', + requestedAt: ctx.requestedAt, + }) + expect(loaded[1]).to.deep.equal({ + user: userB, + privilegeLevel: 'review', + requestedAt: ctx.requestedAt, + }) + }) + + it('drops requests whose user was deleted in the meantime', async function (ctx) { + ctx.UserGetter.promises.getUsers.resolves([ + { + _id: ctx.requesterA, + email: 'a@example.com', + first_name: 'A', + last_name: 'One', + }, + ]) + const access = new ctx.CollaboratorsGetter.ProjectAccess( + ctx.projectWithRequests + ) + const loaded = await access.loadAccessRequests() + expect(loaded).to.have.lengthOf(1) + expect(loaded[0].privilegeLevel).to.equal('readAndWrite') + }) + + it('builds the owner view with each requester’s current privilege level', async function (ctx) { + const projectForView = { + ...ctx.project, + owner_ref: ctx.ownerRef, + readOnly_refs: [ctx.requesterA], + editAccessRequests: [ + { + userId: ctx.requesterA, + privilegeLevel: 'readAndWrite', + requestedAt: ctx.requestedAt, + }, + ], + } + ctx.UserGetter.promises.getUsers.resolves([ + { + _id: ctx.requesterA, + email: 'a@example.com', + first_name: 'A', + last_name: 'One', + }, + ]) + const access = new ctx.CollaboratorsGetter.ProjectAccess(projectForView) + const view = await access.loadAccessRequestsView() + expect(view).to.deep.equal([ + { + _id: ctx.requesterA, + email: 'a@example.com', + first_name: 'A', + last_name: 'One', + privilegeLevel: 'readAndWrite', + currentPrivilegeLevel: 'readOnly', + requestedAt: ctx.requestedAt, + }, + ]) + }) + }) }) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsHandler.test.mjs b/services/web/test/unit/src/Collaborators/CollaboratorsHandler.test.mjs index 744f3c7679..7c5287d6cd 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsHandler.test.mjs +++ b/services/web/test/unit/src/Collaborators/CollaboratorsHandler.test.mjs @@ -137,6 +137,7 @@ describe('CollaboratorsHandler', function () { readOnly_refs: ctx.userId, pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, tokenAccessReadOnly_refs: ctx.userId, tokenAccessReadAndWrite_refs: ctx.userId, archived: ctx.userId, @@ -452,6 +453,7 @@ describe('CollaboratorsHandler', function () { readOnly_refs: ctx.userId, pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, tokenAccessReadOnly_refs: ctx.userId, tokenAccessReadAndWrite_refs: ctx.userId, archived: ctx.userId, @@ -568,6 +570,15 @@ describe('CollaboratorsHandler', function () { ) .chain('exec') .resolves() + ctx.ProjectMock.expects('updateMany') + .withArgs( + { 'editAccessRequests.userId': ctx.fromUserId }, + { + $pull: { editAccessRequests: { userId: ctx.fromUserId } }, + } + ) + .chain('exec') + .resolves() }) describe('successfully', function () { @@ -610,6 +621,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -618,6 +631,9 @@ describe('CollaboratorsHandler', function () { pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, reviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, $addToSet: { readOnly_refs: ctx.userId }, } @@ -640,6 +656,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -649,6 +667,9 @@ describe('CollaboratorsHandler', function () { reviewer_refs: ctx.userId, pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, } ) @@ -679,6 +700,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -689,6 +712,9 @@ describe('CollaboratorsHandler', function () { collaberator_refs: ctx.userId, pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, } ) @@ -722,6 +748,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -732,6 +760,9 @@ describe('CollaboratorsHandler', function () { collaberator_refs: ctx.userId, pendingEditor_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, } ) @@ -754,6 +785,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -765,6 +798,9 @@ describe('CollaboratorsHandler', function () { collaberator_refs: ctx.userId, reviewer_refs: ctx.userId, pendingReviewer_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, } ) @@ -787,6 +823,8 @@ describe('CollaboratorsHandler', function () { { collaberator_refs: ctx.userId }, { readOnly_refs: ctx.userId }, { reviewer_refs: ctx.userId }, + { tokenAccessReadOnly_refs: ctx.userId }, + { tokenAccessReadAndWrite_refs: ctx.userId }, ], }, { @@ -798,6 +836,9 @@ describe('CollaboratorsHandler', function () { collaberator_refs: ctx.userId, reviewer_refs: ctx.userId, pendingEditor_refs: ctx.userId, + editAccessRequests: { userId: ctx.userId }, + tokenAccessReadOnly_refs: ctx.userId, + tokenAccessReadAndWrite_refs: ctx.userId, }, } ) @@ -848,4 +889,124 @@ describe('CollaboratorsHandler', function () { ) }) }) + + describe('requestAccess', function () { + it('rebuilds the array via an aggregation pipeline and reports isNew=true', async function (ctx) { + ctx.ProjectMock.expects('findOneAndUpdate') + .withArgs( + { _id: ctx.project._id }, + sinon.match(update => { + // pipeline update: [{ $set: { editAccessRequests: { $concatArrays: [...] } } }] + const stage = Array.isArray(update) && update[0]?.$set + const concat = stage?.editAccessRequests?.$concatArrays + const filter = concat?.[0]?.$filter + const appended = concat?.[1]?.[0] + return ( + // the entry is stored as an ObjectId (not a session string) so + // later $pulls match — compare by value + filter?.cond?.$ne?.[1] instanceof ObjectId && + filter.cond.$ne[1].equals(ctx.userId) && + appended && + appended.userId instanceof ObjectId && + appended.userId.equals(ctx.userId) && + appended.privilegeLevel === 'readAndWrite' && + appended.requestedAt instanceof Date + ) + }), + sinon.match({ returnDocument: 'before' }) + ) + .chain('exec') + // before-doc has no entry for this user -> isNew + .resolves({ _id: ctx.project._id, editAccessRequests: [] }) + const result = await ctx.CollaboratorsHandler.promises.requestAccess( + ctx.project._id, + ctx.userId, + 'readAndWrite' + ) + expect(result).to.deep.equal({ isNew: true }) + }) + + it('reports isNew=false when the user already had a request', async function (ctx) { + ctx.ProjectMock.expects('findOneAndUpdate') + .chain('exec') + .resolves({ + _id: ctx.project._id, + editAccessRequests: [ + { userId: ctx.userId, privilegeLevel: 'readAndWrite' }, + ], + }) + const result = await ctx.CollaboratorsHandler.promises.requestAccess( + ctx.project._id, + ctx.userId, + 'review' + ) + expect(result).to.deep.equal({ isNew: false }) + }) + + it('reports isNew=false when the project was not found', async function (ctx) { + ctx.ProjectMock.expects('findOneAndUpdate').chain('exec').resolves(null) + const result = await ctx.CollaboratorsHandler.promises.requestAccess( + ctx.project._id, + ctx.userId, + 'readAndWrite' + ) + expect(result).to.deep.equal({ isNew: false }) + }) + + it('rejects unsupported privilege levels', async function (ctx) { + await expect( + ctx.CollaboratorsHandler.promises.requestAccess( + ctx.project._id, + ctx.userId, + 'readOnly' + ) + ).to.be.rejected + await expect( + ctx.CollaboratorsHandler.promises.requestAccess( + ctx.project._id, + ctx.userId, + 'owner' + ) + ).to.be.rejected + }) + }) + + describe('declineAccessRequest', function () { + it('pulls the entry and reports the removed level from the pre-image', async function (ctx) { + ctx.ProjectMock.expects('findOneAndUpdate') + .withArgs( + { _id: ctx.project._id }, + { $pull: { editAccessRequests: { userId: ctx.userId } } }, + sinon.match({ returnDocument: 'before' }) + ) + .chain('exec') + .resolves({ + _id: ctx.project._id, + editAccessRequests: [ + { userId: ctx.userId, privilegeLevel: 'review' }, + ], + }) + const result = + await ctx.CollaboratorsHandler.promises.declineAccessRequest( + ctx.project._id, + ctx.userId + ) + expect(result).to.deep.equal({ removed: true, privilegeLevel: 'review' }) + }) + + it('reports removed=false when the user had no pending request', async function (ctx) { + ctx.ProjectMock.expects('findOneAndUpdate') + .chain('exec') + .resolves({ _id: ctx.project._id, editAccessRequests: [] }) + const result = + await ctx.CollaboratorsHandler.promises.declineAccessRequest( + ctx.project._id, + ctx.userId + ) + expect(result).to.deep.equal({ + removed: false, + privilegeLevel: undefined, + }) + }) + }) }) diff --git a/services/web/test/unit/src/Editor/EditorHttpController.test.mjs b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs index 5b8ea41c8e..bae4070ebc 100644 --- a/services/web/test/unit/src/Editor/EditorHttpController.test.mjs +++ b/services/web/test/unit/src/Editor/EditorHttpController.test.mjs @@ -68,6 +68,7 @@ describe('EditorHttpController', function () { } const members = ctx.members const ownerMember = ctx.ownerMember + const ownerId = ctx.ownerId ctx.CollaboratorsGetter = { ProjectAccess: class { loadOwnerAndInvitedMembers() { @@ -85,6 +86,22 @@ describe('EditorHttpController', function () { isUserInvitedMember() { return false } + + getOwnerId() { + return ownerId + } + + loadAccessRequestsView() { + return [] + } + + getAccessRequestForUser() { + return null + } + + privilegeLevelForUser() { + return 'readOnly' + } }, promises: { isUserInvitedMemberOfProject: sinon.stub().resolves(false), @@ -366,6 +383,40 @@ describe('EditorHttpController', function () { }) }) + describe('with a restricted user who has a pending access request', function () { + beforeEach(async function (ctx) { + ctx.accessRequest = { + privilegeLevel: 'readAndWrite', + requestedAt: new Date(), + } + sinon + .stub( + ctx.CollaboratorsGetter.ProjectAccess.prototype, + 'getAccessRequestForUser' + ) + .returns(ctx.accessRequest) + ctx.ProjectEditorHandler.buildProjectModelView.returns( + ctx.reducedProjectView + ) + ctx.AuthorizationManager.isRestrictedUser.returns(true) + ctx.AuthorizationManager.promises.getPrivilegeLevelForProjectWithProjectAccess.resolves( + 'readOnly' + ) + await new Promise(resolve => { + ctx.res.callback = resolve + ctx.EditorHttpController.joinProject(ctx.req, ctx.res) + }) + }) + + it('surfaces the caller’s own access request in the project view', function (ctx) { + expect( + ctx.ProjectEditorHandler.buildProjectModelView + ).to.have.been.calledWith(ctx.project, ctx.ownerMember, [], [], true, { + myAccessRequest: ctx.accessRequest, + }) + }) + }) + describe('when not authorized', function () { beforeEach(async function (ctx) { await new Promise(resolve => { diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index 5c75e80451..ee8afdcbe8 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/services/web/test/unit/src/Email/EmailBuilder.test.mjs @@ -1219,4 +1219,112 @@ describe('EmailBuilder', function () { }) }) }) + + describe('accessRequest', function () { + beforeEach(function (ctx) { + ctx.opts = { + to: 'owner@example.com', + project: { _id: 'abc123', name: 'My Project' }, + owner: { + email: 'owner@example.com', + first_name: 'O', + last_name: 'wner', + }, + requester: { + email: 'req@example.com', + first_name: 'Re', + last_name: 'Quester', + }, + privilegeLevel: 'readAndWrite', + } + }) + + it('names the requester and project in the subject', function (ctx) { + const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts) + expect(email.subject).to.contain('req@example.com') + expect(email.subject).to.contain('My Project') + expect(email.subject).to.contain('editor') + }) + + it('says "reviewer" in the subject when the review level was requested', function (ctx) { + ctx.opts.privilegeLevel = 'review' + const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts) + expect(email.subject).to.contain('reviewer') + expect(email.subject).to.not.contain('editor access') + }) + + it('deep-links the CTA back to the share modal via ?share=1', function (ctx) { + const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts) + expect(email.text).to.contain( + 'https://www.overleaf.com/project/abc123?share=1' + ) + }) + + it('does not leave any "undefined" placeholders', function (ctx) { + const email = ctx.EmailBuilder.buildEmail('accessRequest', ctx.opts) + expect(email.subject).to.not.contain('undefined') + expect(email.html).to.not.contain('undefined') + expect(email.text).to.not.contain('undefined') + }) + }) + + describe('accessRequestGranted', function () { + beforeEach(function (ctx) { + ctx.opts = { + to: 'req@example.com', + project: { _id: 'abc123', name: 'My Project' }, + requester: { + email: 'req@example.com', + first_name: 'Re', + last_name: 'Quester', + }, + privilegeLevel: 'readAndWrite', + } + }) + + it('mentions the granted role in the message', function (ctx) { + const email = ctx.EmailBuilder.buildEmail( + 'accessRequestGranted', + ctx.opts + ) + expect(email.text).to.contain('editor') + expect(email.text).to.contain('My Project') + }) + + it('switches to reviewer wording when the granted level was review', function (ctx) { + ctx.opts.privilegeLevel = 'review' + const email = ctx.EmailBuilder.buildEmail( + 'accessRequestGranted', + ctx.opts + ) + expect(email.text).to.contain('reviewer') + }) + + it('points the CTA at the project (no ?share=1, owner does not need it)', function (ctx) { + const email = ctx.EmailBuilder.buildEmail( + 'accessRequestGranted', + ctx.opts + ) + expect(email.text).to.contain('https://www.overleaf.com/project/abc123') + expect(email.text).to.not.contain('share=1') + }) + }) + + describe('accessRequestDeclined', function () { + it('is a NoCTA email mentioning the project name', function (ctx) { + const email = ctx.EmailBuilder.buildEmail('accessRequestDeclined', { + to: 'req@example.com', + project: { _id: 'abc123', name: 'My Project' }, + requester: { + email: 'req@example.com', + first_name: 'Re', + last_name: 'Quester', + }, + }) + expect(email.subject).to.contain('My Project') + expect(email.subject).to.contain('declined') + expect(email.text).to.contain('My Project') + expect(email.text).to.contain('declined') + }) + }) }) diff --git a/services/web/test/unit/src/Project/ProjectController.test.mjs b/services/web/test/unit/src/Project/ProjectController.test.mjs index d3d74ac719..35ef2693e0 100644 --- a/services/web/test/unit/src/Project/ProjectController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectController.test.mjs @@ -207,6 +207,7 @@ describe('ProjectController', function () { promises: { getAssignment: sinon.stub().resolves({ variant: 'default' }), getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }), + featureFlagEnabledForUser: sinon.stub().resolves(false), hasUserBeenAssignedToVariant: sinon.stub().resolves(false), }, getAssignment: sinon.stub().yields(null, { variant: 'default' }), diff --git a/services/web/types/utils.ts b/services/web/types/utils.ts index 07b9f45353..86981ed75b 100644 --- a/services/web/types/utils.ts +++ b/services/web/types/utils.ts @@ -23,6 +23,8 @@ export type Keys = (keyof T)[] export type ExcludeStrict = Exclude +export type ExtractStrict = Extract + /** * Helper to create type guards for literal unions *