diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee index 1905d0d0a6..a889e9183a 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsController.coffee @@ -4,7 +4,9 @@ ProjectEditorHandler = require "../Project/ProjectEditorHandler" EditorRealTimeController = require "../Editor/EditorRealTimeController" LimitationsManager = require "../Subscription/LimitationsManager" UserGetter = require "../User/UserGetter" -mimelib = require("mimelib") +EmailHelper = require "../Helpers/EmailHelper" +logger = require 'logger-sharelatex' + module.exports = CollaboratorsController = addUserToProject: (req, res, next) -> @@ -16,11 +18,11 @@ module.exports = CollaboratorsController = return res.json { user: false } else {email, privileges} = req.body - - email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase() + + email = EmailHelper.parseEmail(email) if !email? or email == "" return res.status(400).send("invalid email address") - + adding_user_id = req.session?.user?._id CollaboratorsHandler.addEmailToProject project_id, adding_user_id, email, privileges, (error, user_id) => return next(error) if error? @@ -35,8 +37,9 @@ module.exports = CollaboratorsController = user_id = req.params.user_id CollaboratorsController._removeUserIdFromProject project_id, user_id, (error) -> return next(error) if error? + EditorRealTimeController.emitToRoom project_id, 'project:membership:changed', {members: true} res.sendStatus 204 - + removeSelfFromProject: (req, res, next = (error) ->) -> project_id = req.params.Project_id user_id = req.session?.user?._id @@ -50,3 +53,11 @@ module.exports = CollaboratorsController = EditorRealTimeController.emitToRoom(project_id, 'userRemovedFromProject', user_id) callback() + getAllMembers: (req, res, next) -> + projectId = req.params.Project_id + logger.log {projectId}, "getting all active members for project" + CollaboratorsHandler.getAllMembers projectId, (err, members) -> + if err? + logger.err {projectId}, "error getting members for project" + return next(err) + res.json({members: members}) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee index e9beb1bb43..f669d85de4 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsEmailHandler.coffee @@ -2,7 +2,15 @@ Project = require("../../models/Project").Project EmailHandler = require("../Email/EmailHandler") Settings = require "settings-sharelatex" -module.exports = + +module.exports = CollaboratorsEmailHandler = + + _buildInviteUrl: (project, invite) -> + "#{Settings.siteUrl}/project/#{project._id}/invite/token/#{invite.token}?" + [ + "project_name=#{encodeURIComponent(project.name)}" + "user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}" + ].join("&") + notifyUserOfProjectShare: (project_id, email, callback)-> Project .findOne(_id: project_id ) @@ -22,4 +30,19 @@ module.exports = "rs=ci" # referral source = collaborator invite ].join("&") owner: project.owner_ref - EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback \ No newline at end of file + EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback + + notifyUserOfProjectInvite: (project_id, email, invite, callback)-> + Project + .findOne(_id: project_id ) + .select("name owner_ref") + .populate('owner_ref') + .exec (err, project)-> + emailOptions = + to: email + replyTo: project.owner_ref.email + project: + name: project.name + inviteUrl: CollaboratorsEmailHandler._buildInviteUrl(project, invite) + owner: project.owner_ref + EmailHandler.sendEmail "projectInvite", emailOptions, callback diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee index 81557fea42..e974698b18 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsHandler.coffee @@ -1,6 +1,5 @@ UserCreator = require('../User/UserCreator') Project = require("../../models/Project").Project -mimelib = require("mimelib") logger = require('logger-sharelatex') UserGetter = require "../User/UserGetter" ContactManager = require "../Contacts/ContactManager" @@ -8,8 +7,12 @@ CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" async = require "async" PrivilegeLevels = require "../Authorization/PrivilegeLevels" Errors = require "../Errors/Errors" +EmailHelper = require "../Helpers/EmailHelper" +ProjectEditorHandler = require "../Project/ProjectEditorHandler" + module.exports = CollaboratorsHandler = + getMemberIdsWithPrivilegeLevels: (project_id, callback = (error, members) ->) -> Project.findOne { _id: project_id }, { owner_ref: 1, collaberator_refs: 1, readOnly_refs: 1 }, (error, project) -> return callback(error) if error? @@ -21,12 +24,12 @@ module.exports = CollaboratorsHandler = for member_id in project.collaberator_refs or [] members.push { id: member_id.toString(), privilegeLevel: PrivilegeLevels.READ_AND_WRITE } return callback null, members - + getMemberIds: (project_id, callback = (error, member_ids) ->) -> CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) -> return callback(error) if error? return callback null, members.map (m) -> m.id - + getMembersWithPrivilegeLevels: (project_id, callback = (error, members) ->) -> CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members = []) -> return callback(error) if error? @@ -41,7 +44,7 @@ module.exports = CollaboratorsHandler = (error) -> return callback(error) if error? callback null, result - + getMemberIdPrivilegeLevel: (user_id, project_id, callback = (error, privilegeLevel) ->) -> # In future if the schema changes and getting all member ids is more expensive (multiple documents) # then optimise this. @@ -51,12 +54,12 @@ module.exports = CollaboratorsHandler = if member.id == user_id?.toString() return callback null, member.privilegeLevel return callback null, PrivilegeLevels.NONE - + getMemberCount: (project_id, callback = (error, count) ->) -> CollaboratorsHandler.getMemberIdsWithPrivilegeLevels project_id, (error, members) -> return callback(error) if error? return callback null, (members or []).length - + getCollaboratorCount: (project_id, callback = (error, count) ->) -> CollaboratorsHandler.getMemberCount project_id, (error, count) -> return callback(error) if error? @@ -69,14 +72,14 @@ module.exports = CollaboratorsHandler = if member.id.toString() == user_id.toString() return callback null, true, member.privilegeLevel return callback null, false, null - + getProjectsUserIsCollaboratorOf: (user_id, fields, callback = (error, readAndWriteProjects, readOnlyProjects) ->) -> Project.find {collaberator_refs:user_id}, fields, (err, readAndWriteProjects)=> return callback(err) if err? Project.find {readOnly_refs:user_id}, fields, (err, readOnlyProjects)=> return callback(err) if err? callback(null, readAndWriteProjects, readOnlyProjects) - + removeUserFromProject: (project_id, user_id, callback = (error) ->)-> logger.log user_id: user_id, project_id: project_id, "removing user" conditions = _id:project_id @@ -86,7 +89,7 @@ module.exports = CollaboratorsHandler = if err? logger.error err: err, "problem removing user from project collaberators" callback(err) - + removeUserFromAllProjets: (user_id, callback = (error) ->) -> CollaboratorsHandler.getProjectsUserIsCollaboratorOf user_id, { _id: 1 }, (error, readAndWriteProjects = [], readOnlyProjects = []) -> return callback(error) if error? @@ -98,10 +101,9 @@ module.exports = CollaboratorsHandler = return cb() if !project? CollaboratorsHandler.removeUserFromProject project._id, user_id, cb async.series jobs, callback - + addEmailToProject: (project_id, adding_user_id, unparsed_email, privilegeLevel, callback = (error, user) ->) -> - emails = mimelib.parseAddresses(unparsed_email) - email = emails[0]?.address?.toLowerCase() + email = EmailHelper.parseEmail(unparsed_email) if !email? or email == "" return callback(new Error("no valid email provided: '#{unparsed_email}'")) UserCreator.getUserOrCreateHoldingAccount email, (error, user) -> @@ -118,7 +120,7 @@ module.exports = CollaboratorsHandler = existing_users = existing_users.map (u) -> u.toString() if existing_users.indexOf(user_id.toString()) > -1 return callback null # User already in Project - + if privilegeLevel == PrivilegeLevels.READ_AND_WRITE level = {"collaberator_refs":user_id} logger.log {privileges: "readAndWrite", user_id, project_id}, "adding user" @@ -128,11 +130,6 @@ module.exports = CollaboratorsHandler = else return callback(new Error("unknown privilegeLevel: #{privilegeLevel}")) - # Do these in the background - UserGetter.getUser user_id, {email: 1}, (error, user) -> - if error? - logger.error {err: error, project_id, user_id}, "error getting user while adding to project" - CollaboratorsEmailHandler.notifyUserOfProjectShare project_id, user.email ContactManager.addContact adding_user_id, user_id Project.update { _id: project_id }, { $addToSet: level }, (error) -> @@ -143,3 +140,12 @@ module.exports = CollaboratorsHandler = if error? logger.error {err: error, project_id, user_id}, "error flushing to TPDS after adding collaborator" callback() + + getAllMembers: (projectId, callback=(err, members)->) -> + logger.log {projectId}, "fetching all members" + CollaboratorsHandler.getMembersWithPrivilegeLevels projectId, (error, rawMembers) -> + if error? + logger.err {projectId, error}, "error getting members for project" + return callback(error) + {owner, members} = ProjectEditorHandler.buildOwnerAndMembersViews(rawMembers) + callback(null, members) diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee new file mode 100644 index 0000000000..216e2f8a01 --- /dev/null +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteController.coffee @@ -0,0 +1,123 @@ +ProjectGetter = require "../Project/ProjectGetter" +LimitationsManager = require "../Subscription/LimitationsManager" +UserGetter = require "../User/UserGetter" +CollaboratorsHandler = require('./CollaboratorsHandler') +CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler') +logger = require('logger-sharelatex') +EmailHelper = require "../Helpers/EmailHelper" +EditorRealTimeController = require("../Editor/EditorRealTimeController") +NotificationsBuilder = require("../Notifications/NotificationsBuilder") + + +module.exports = CollaboratorsInviteController = + + getAllInvites: (req, res, next) -> + projectId = req.params.Project_id + logger.log {projectId}, "getting all active invites for project" + CollaboratorsInviteHandler.getAllInvites projectId, (err, invites) -> + if err? + logger.err {projectId}, "error getting invites for project" + return next(err) + res.json({invites: invites}) + + inviteToProject: (req, res, next) -> + projectId = req.params.Project_id + email = req.body.email + sendingUser = req.session.user + sendingUserId = sendingUser._id + logger.log {projectId, email, sendingUserId}, "inviting to project" + LimitationsManager.canAddXCollaborators projectId, 1, (error, allowed) => + return next(error) if error? + if !allowed + logger.log {projectId, email, sendingUserId}, "not allowed to invite more users to project" + return res.json {invite: null} + {email, privileges} = req.body + email = EmailHelper.parseEmail(email) + if !email? or email == "" + logger.log {projectId, email, sendingUserId}, "invalid email address" + return res.sendStatus(400) + CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) -> + if err? + logger.err {projectId, email, sendingUserId}, "error creating project invite" + return next(err) + logger.log {projectId, email, sendingUserId}, "invite created" + EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {invites: true}) + return res.json {invite: invite} + + revokeInvite: (req, res, next) -> + projectId = req.params.Project_id + inviteId = req.params.invite_id + logger.log {projectId, inviteId}, "revoking invite" + CollaboratorsInviteHandler.revokeInvite projectId, inviteId, (err) -> + if err? + logger.err {projectId, inviteId}, "error revoking invite" + return next(err) + EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true} + res.sendStatus(201) + + resendInvite: (req, res, next) -> + projectId = req.params.Project_id + inviteId = req.params.invite_id + sendingUser = req.session.user + logger.log {projectId, inviteId}, "resending invite" + CollaboratorsInviteHandler.resendInvite projectId, sendingUser, inviteId, (err) -> + if err? + logger.err {projectId, inviteId}, "error resending invite" + return next(err) + res.sendStatus(201) + + viewInvite: (req, res, next) -> + projectId = req.params.Project_id + token = req.params.token + currentUser = req.session.user + _renderInvalidPage = () -> + logger.log {projectId, token}, "invite not valid, rendering not-valid page" + res.render "project/invite/not-valid", {title: "Invalid Invite"} + # check if the user is already a member of the project + CollaboratorsHandler.isUserMemberOfProject currentUser._id, projectId, (err, isMember, _privilegeLevel) -> + if err? + logger.err {err, projectId}, "error checking if user is member of project" + return next(err) + if isMember + logger.log {projectId, userId: currentUser._id}, "user is already a member of this project, redirecting" + return res.redirect "/project/#{projectId}" + # get the invite + CollaboratorsInviteHandler.getInviteByToken projectId, token, (err, invite) -> + if err? + logger.err {projectId, token}, "error getting invite by token" + return next(err) + # check if invite is gone, or otherwise non-existent + if !invite? + logger.log {projectId, token}, "no invite found for this token" + return _renderInvalidPage() + # check the user who sent the invite exists + UserGetter.getUser {_id: invite.sendingUserId}, {email: 1, first_name: 1, last_name: 1}, (err, owner) -> + if err? + logger.err {err, projectId}, "error getting project owner" + return next(err) + if !owner? + logger.log {projectId}, "no project owner found" + return _renderInvalidPage() + # fetch the project name + ProjectGetter.getProject projectId, {}, (err, project) -> + if err? + logger.err {err, projectId}, "error getting project" + return next(err) + if !project? + logger.log {projectId}, "no project found" + return _renderInvalidPage() + # finally render the invite + res.render "project/invite/show", {invite, project, owner, title: "Project Invite"} + + acceptInvite: (req, res, next) -> + projectId = req.params.Project_id + inviteId = req.params.invite_id + {token} = req.body + currentUser = req.session.user + logger.log {projectId, inviteId, userId: currentUser._id}, "accepting invite" + CollaboratorsInviteHandler.acceptInvite projectId, inviteId, token, currentUser, (err) -> + if err? + logger.err {projectId, inviteId}, "error accepting invite by token" + return next(err) + EditorRealTimeController.emitToRoom projectId, 'project:membership:changed', {invites: true, members: true} + res.redirect "/project/#{projectId}" diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee new file mode 100644 index 0000000000..bf03fe242d --- /dev/null +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsInviteHandler.coffee @@ -0,0 +1,142 @@ +ProjectInvite = require("../../models/ProjectInvite").ProjectInvite +logger = require('logger-sharelatex') +CollaboratorsEmailHandler = require "./CollaboratorsEmailHandler" +CollaboratorsHandler = require "./CollaboratorsHandler" +UserGetter = require "../User/UserGetter" +ProjectGetter = require "../Project/ProjectGetter" +Async = require "async" +PrivilegeLevels = require "../Authorization/PrivilegeLevels" +Errors = require "../Errors/Errors" +Crypto = require 'crypto' +NotificationsBuilder = require("../Notifications/NotificationsBuilder") + + +module.exports = CollaboratorsInviteHandler = + + getAllInvites: (projectId, callback=(err, invites)->) -> + logger.log {projectId}, "fetching invites for project" + ProjectInvite.find {projectId: projectId}, (err, invites) -> + if err? + logger.err {err, projectId}, "error getting invites from mongo" + return callback(err) + logger.log {projectId, count: invites.length}, "found invites for project" + callback(null, invites) + + getInviteCount: (projectId, callback=(err, count)->) -> + logger.log {projectId}, "counting invites for project" + ProjectInvite.count {projectId: projectId}, (err, count) -> + if err? + logger.err {err, projectId}, "error getting invites from mongo" + return callback(err) + callback(null, count) + + _trySendInviteNotification: (projectId, sendingUser, invite, callback=(err)->) -> + email = invite.email + UserGetter.getUser {email: email}, {_id: 1}, (err, existingUser) -> + if err? + logger.err {projectId, email}, "error checking if user exists" + return callback(err) + if !existingUser? + logger.log {projectId, email}, "no existing user found, returning" + return callback(null) + ProjectGetter.getProject projectId, {_id: 1, name: 1}, (err, project) -> + if err? + logger.err {projectId, email}, "error getting project" + return callback(err) + if !project? + logger.log {projectId}, "no project found while sending notification, returning" + return callback(null) + NotificationsBuilder.projectInvite(invite, project, sendingUser, existingUser).create(callback) + + _tryCancelInviteNotification: (inviteId, callback=()->) -> + NotificationsBuilder.projectInvite({_id: inviteId}, null, null, null).read(callback) + + _sendMessages: (projectId, sendingUser, invite, callback=(err)->) -> + logger.log {projectId, inviteId: invite._id}, "sending notification and email for invite" + CollaboratorsEmailHandler.notifyUserOfProjectInvite projectId, invite.email, invite, (err)-> + return callback(err) if err? + CollaboratorsInviteHandler._trySendInviteNotification projectId, sendingUser, invite, (err)-> + return callback(err) if err? + callback() + + inviteToProject: (projectId, sendingUser, email, privileges, callback=(err,invite)->) -> + logger.log {projectId, sendingUserId: sendingUser._id, email, privileges}, "adding invite" + Crypto.randomBytes 24, (err, buffer) -> + if err? + logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error generating random token" + return callback(err) + token = buffer.toString('hex') + invite = new ProjectInvite { + email: email + token: token + sendingUserId: sendingUser._id + projectId: projectId + privileges: privileges + } + invite.save (err, invite) -> + if err? + logger.err {err, projectId, sendingUserId: sendingUser._id, email}, "error saving token" + return callback(err) + CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) -> + if err? + logger.err {projectId, email}, "error sending messages for invite" + callback(err, invite) + + revokeInvite: (projectId, inviteId, callback=(err)->) -> + logger.log {projectId, inviteId}, "removing invite" + ProjectInvite.remove {projectId: projectId, _id: inviteId}, (err) -> + if err? + logger.err {err, projectId, inviteId}, "error removing invite" + return callback(err) + CollaboratorsInviteHandler._tryCancelInviteNotification(inviteId, ()->) + callback(null) + + resendInvite: (projectId, sendingUser, inviteId, callback=(err)->) -> + logger.log {projectId, inviteId}, "resending invite email" + ProjectInvite.findOne {_id: inviteId, projectId: projectId}, (err, invite) -> + if err? + logger.err {err, projectId, inviteId}, "error finding invite" + return callback(err) + if !invite? + logger.err {err, projectId, inviteId}, "no invite found, nothing to resend" + return callback(null) + CollaboratorsInviteHandler._sendMessages projectId, sendingUser, invite, (err) -> + if err? + logger.err {projectid, inviteId}, "error resending invite messages" + return callback(err) + callback(null) + + getInviteByToken: (projectId, tokenString, callback=(err,invite)->) -> + logger.log {projectId, tokenString}, "fetching invite by token" + ProjectInvite.findOne {projectId: projectId, token: tokenString}, (err, invite) -> + if err? + logger.err {err, projectId}, "error fetching invite" + return callback(err) + if !invite? + logger.err {err, projectId, token: tokenString}, "no invite found" + return callback(null, null) + callback(null, invite) + + acceptInvite: (projectId, inviteId, tokenString, user, callback=(err)->) -> + logger.log {projectId, inviteId, userId: user._id}, "accepting invite" + CollaboratorsInviteHandler.getInviteByToken projectId, tokenString, (err, invite) -> + if err? + logger.err {err, projectId, inviteId}, "error finding invite" + return callback(err) + if !invite + err = new Errors.NotFoundError("no matching invite found") + logger.log {err, projectId, inviteId, tokenString}, "no matching invite found" + return callback(err) + inviteId = invite._id + CollaboratorsHandler.addUserIdToProject projectId, invite.sendingUserId, user._id, invite.privileges, (err) -> + if err? + logger.err {err, projectId, inviteId, userId: user._id}, "error adding user to project" + return callback(err) + # Remove invite + logger.log {projectId, inviteId}, "removing invite" + ProjectInvite.remove {_id: inviteId}, (err) -> + if err? + logger.err {err, projectId, inviteId}, "error removing invite" + return callback(err) + CollaboratorsInviteHandler._tryCancelInviteNotification inviteId, ()-> + callback() diff --git a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee index 34a6da9a02..7fc4722ef2 100644 --- a/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee +++ b/services/web/app/coffee/Features/Collaborators/CollaboratorsRouter.coffee @@ -1,6 +1,8 @@ CollaboratorsController = require('./CollaboratorsController') AuthenticationController = require('../Authentication/AuthenticationController') AuthorizationMiddlewear = require('../Authorization/AuthorizationMiddlewear') +CollaboratorsInviteController = require('./CollaboratorsInviteController') +RateLimiterMiddlewear = require('../Security/RateLimiterMiddlewear') module.exports = apply: (webRouter, apiRouter) -> @@ -8,3 +10,63 @@ module.exports = webRouter.post '/project/:Project_id/users', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.addUserToProject webRouter.delete '/project/:Project_id/users/:user_id', AuthorizationMiddlewear.ensureUserCanAdminProject, CollaboratorsController.removeUserFromProject + + webRouter.get( + '/project/:Project_id/members', + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanAdminProject, + CollaboratorsController.getAllMembers + ) + + # invites + webRouter.post( + '/project/:Project_id/invite', + RateLimiterMiddlewear.rateLimit({ + endpointName: "invite-to-project" + params: ["Project_id"] + maxRequests: 200 + timeInterval: 60 * 10 + }), + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanAdminProject, + CollaboratorsInviteController.inviteToProject + ) + + webRouter.get( + '/project/:Project_id/invites', + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanAdminProject, + CollaboratorsInviteController.getAllInvites + ) + + webRouter.delete( + '/project/:Project_id/invite/:invite_id', + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanAdminProject, + CollaboratorsInviteController.revokeInvite + ) + + webRouter.post( + '/project/:Project_id/invite/:invite_id/resend', + RateLimiterMiddlewear.rateLimit({ + endpointName: "resend-invite" + params: ["Project_id"] + maxRequests: 200 + timeInterval: 60 * 10 + }), + AuthenticationController.requireLogin(), + AuthorizationMiddlewear.ensureUserCanAdminProject, + CollaboratorsInviteController.resendInvite + ) + + webRouter.get( + '/project/:Project_id/invite/token/:token', + AuthenticationController.requireLogin(), + CollaboratorsInviteController.viewInvite + ) + + webRouter.post( + '/project/:Project_id/invite/:invite_id/accept', + AuthenticationController.requireLogin(), + CollaboratorsInviteController.acceptInvite + ) diff --git a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee index 379f20fe5b..0c547e53ba 100644 --- a/services/web/app/coffee/Features/Editor/EditorHttpController.coffee +++ b/services/web/app/coffee/Features/Editor/EditorHttpController.coffee @@ -9,6 +9,7 @@ AuthorizationManager = require("../Authorization/AuthorizationManager") ProjectEditorHandler = require('../Project/ProjectEditorHandler') Metrics = require('../../infrastructure/Metrics') CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") +CollaboratorsInviteHandler = require("../Collaborators/CollaboratorsInviteHandler") PrivilegeLevels = require "../Authorization/PrivilegeLevels" module.exports = EditorHttpController = @@ -30,6 +31,7 @@ module.exports = EditorHttpController = ProjectDeleter.unmarkAsDeletedByExternalSource project_id _buildJoinProjectView: (project_id, user_id, callback = (error, project, privilegeLevel) ->) -> + logger.log {project_id, user_id}, "building the joinProject view" ProjectGetter.getProjectWithoutDocLines project_id, (error, project) -> return callback(error) if error? return callback(new Error("not found")) if !project? @@ -40,10 +42,13 @@ module.exports = EditorHttpController = AuthorizationManager.getPrivilegeLevelForProject user_id, project_id, (error, privilegeLevel) -> return callback(error) if error? if !privilegeLevel? or privilegeLevel == PrivilegeLevels.NONE - callback null, null, false - else + logger.log {project_id, user_id, privilegeLevel}, "not an acceptable privilege level, returning null" + return callback null, null, false + CollaboratorsInviteHandler.getAllInvites project_id, (error, invites) -> + return callback(error) if error? + logger.log {project_id, user_id, memberCount: members.length, inviteCount: invites.length, privilegeLevel}, "returning project model view" callback(null, - ProjectEditorHandler.buildProjectModelView(project, members), + ProjectEditorHandler.buildProjectModelView(project, members, invites), privilegeLevel ) @@ -135,5 +140,3 @@ module.exports = EditorHttpController = EditorController.deleteEntity project_id, entity_id, entity_type, "editor", (error) -> return next(error) if error? res.sendStatus 204 - - diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index 4bcf0c671d..f7a7a78a05 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -7,7 +7,7 @@ settings = require("settings-sharelatex") templates = {} -templates.registered = +templates.registered = subject: _.template "Activate your #{settings.appName} Account" layout: PersonalEmailLayout type: "notification" @@ -19,7 +19,7 @@ templates.registered =

If you have any questions or problems, please contact #{settings.adminEmail}.

""" -templates.canceledSubscription = +templates.canceledSubscription = subject: _.template "ShareLaTeX thoughts" layout: PersonalEmailLayout type:"lifecycle" @@ -36,7 +36,7 @@ ShareLaTeX Co-founder

''' -templates.passwordResetRequested = +templates.passwordResetRequested = subject: _.template "Password Reset - #{settings.appName}" layout: NotificationEmailLayout type:"notification" @@ -66,7 +66,7 @@ If you didn't request a password reset, let us know.

#{settings.appName}

""" -templates.projectSharedWithYou = +templates.projectSharedWithYou = subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you" layout: NotificationEmailLayout type:"notification" @@ -87,8 +87,24 @@ templates.projectSharedWithYou =

#{settings.appName}

""" +templates.projectInvite = + subject: _.template "<%= project.name %> - shared by <%= owner.email %>" + layout: NotificationEmailLayout + type:"notification" + compiledTemplate: _.template """ +

Hi, <%= owner.email %> wants to share '<%= project.name %>' with you

+
+ + + View Project + + +
+

Thank you

+

#{settings.appName}

+""" -templates.completeJoinGroupAccount = +templates.completeJoinGroupAccount = subject: _.template "Verify Email to join <%= group_name %> group" layout: NotificationEmailLayout type:"notification" @@ -123,4 +139,3 @@ module.exports = html: template.layout(opts) type:template.type } - diff --git a/services/web/app/coffee/Features/Helpers/EmailHelper.coffee b/services/web/app/coffee/Features/Helpers/EmailHelper.coffee new file mode 100644 index 0000000000..7bc93888fd --- /dev/null +++ b/services/web/app/coffee/Features/Helpers/EmailHelper.coffee @@ -0,0 +1,11 @@ +mimelib = require("mimelib") + + +module.exports = EmailHelper = + + parseEmail: (email) -> + email = mimelib.parseAddresses(email or "")[0]?.address?.toLowerCase() + if !email? or email == "" + return null + else + return email diff --git a/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee index 6f89525629..646a520a1c 100644 --- a/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee +++ b/services/web/app/coffee/Features/Notifications/NotificationsBuilder.coffee @@ -1,16 +1,31 @@ logger = require("logger-sharelatex") NotificationsHandler = require("./NotificationsHandler") -module.exports = +module.exports = + + # Note: notification keys should be url-safe groupPlan: (user, licence)-> key : "join-sub-#{licence.subscription_id}" create: (callback = ->)-> - messageOpts = + messageOpts = groupName: licence.name subscription_id: licence.subscription_id - logger.log user_id:user._id, key:@key, "creating notification key for user" - NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, callback + logger.log user_id:user._id, key:key, "creating notification key for user" + NotificationsHandler.createNotification user._id, @key, "notification_group_invite", messageOpts, null, false, callback read: (callback = ->)-> NotificationsHandler.markAsReadWithKey user._id, @key, callback + + projectInvite: (invite, project, sendingUser, user) -> + key: "project-invite-#{invite._id}" + create: (callback=()->) -> + messageOpts = + userName: sendingUser.first_name + projectName: project.name + projectId: project._id.toString() + token: invite.token + logger.log {user_id: user._id, project_id: project._id, invite_id: invite._id, key: @key}, "creating project invite notification for user" + NotificationsHandler.createNotification user._id, @key, "notification_project_invite", messageOpts, invite.expires, true, callback + read: (callback=()->) -> + NotificationsHandler.markAsReadByKeyOnly @key, callback diff --git a/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee index a7cf6a4672..2ee97dae49 100644 --- a/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee +++ b/services/web/app/coffee/Features/Notifications/NotificationsHandler.coffee @@ -10,10 +10,10 @@ makeRequest = (opts, callback)-> else request(opts, callback) -module.exports = +module.exports = getUserNotifications: (user_id, callback)-> - opts = + opts = uri: "#{settings.apis.notifications?.url}/user/#{user_id}" json: true timeout: oneSecond @@ -29,21 +29,26 @@ module.exports = unreadNotifications = [] callback(null, unreadNotifications) - createNotification: (user_id, key, templateKey, messageOpts, callback)-> - opts = - uri: "#{settings.apis.notifications?.url}/user/#{user_id}" - timeout: oneSecond - method:"POST" - json: { + createNotification: (user_id, key, templateKey, messageOpts, expiryDateTime, forceCreate, callback)-> + payload = { key:key messageOpts:messageOpts templateKey:templateKey - } + } + if expiryDateTime? + payload.expires = expiryDateTime + if forceCreate + payload.forceCreate = true + opts = + uri: "#{settings.apis.notifications?.url}/user/#{user_id}" + timeout: oneSecond + method:"POST" + json: payload logger.log opts:opts, "creating notification for user" makeRequest opts, callback markAsReadWithKey: (user_id, key, callback)-> - opts = + opts = uri: "#{settings.apis.notifications?.url}/user/#{user_id}" method: "DELETE" timeout: oneSecond @@ -52,7 +57,7 @@ module.exports = } logger.log user_id:user_id, key:key, "sending mark notification as read with key to notifications api" makeRequest opts, callback - + markAsRead: (user_id, notification_id, callback)-> opts = @@ -61,3 +66,13 @@ module.exports = timeout:oneSecond logger.log user_id:user_id, notification_id:notification_id, "sending mark notification as read to notifications api" makeRequest opts, callback + + # removes notification by key, without regard for user_id, + # should not be exposed to user via ui/router + markAsReadByKeyOnly: (key, callback)-> + opts = + uri: "#{settings.apis.notifications?.url}/key/#{key}" + method: "DELETE" + timeout: oneSecond + logger.log {key:key}, "sending mark notification as read with key-only to notifications api" + makeRequest opts, callback diff --git a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee index 4e4991a855..1a10321544 100644 --- a/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEditorHandler.coffee @@ -1,7 +1,7 @@ _ = require("underscore") module.exports = ProjectEditorHandler = - buildProjectModelView: (project, members) -> + buildProjectModelView: (project, members, invites) -> result = _id : project._id name : project.name @@ -15,17 +15,16 @@ module.exports = ProjectEditorHandler = deletedByExternalDataSource : project.deletedByExternalDataSource || false deletedDocs: project.deletedDocs members: [] - - owner = null - for member in members - if member.privilegeLevel == "owner" - owner = member.user - else - result.members.push @buildUserModelView member.user, member.privilegeLevel - if owner? - result.owner = @buildUserModelView owner, "owner" + invites: invites - result.features = _.defaults(owner?.features or {}, { + if !result.invites? + result.invites = [] + + {owner, ownerFeatures, members} = @buildOwnerAndMembersViews(members) + result.owner = owner + result.members = members + + result.features = _.defaults(ownerFeatures or {}, { collaborators: -1 # Infinite versioning: false dropbox:false @@ -37,6 +36,18 @@ module.exports = ProjectEditorHandler = return result + buildOwnerAndMembersViews: (members) -> + owner = null + ownerFeatures = null + filteredMembers = [] + for member in members + if member.privilegeLevel == "owner" + ownerFeatures = member.user.features + owner = @buildUserModelView member.user, "owner" + else + filteredMembers.push @buildUserModelView member.user, member.privilegeLevel + {owner: owner, ownerFeatures: ownerFeatures, members: filteredMembers} + buildUserModelView: (user, privileges) -> _id : user._id first_name : user.first_name diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index c68f732f16..ca783a2cbf 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -258,6 +258,7 @@ module.exports = ProjectEntityHandler = if !foundFolder? logger.log path:path, project_id:project._id, folderName:folderName, "making folder from mkdirp" @addFolder project_id, parentFolder_id, folderName, (err, newFolder, parentFolder_id)-> + return callback(err) if err? newFolder.parentFolder_id = parentFolder_id previousFolders.push newFolder callback null, previousFolders @@ -268,6 +269,7 @@ module.exports = ProjectEntityHandler = async.reduce folders, [], procesFolder, (err, folders)-> + return callback(err) if err? lastFolder = folders[folders.length-1] folders = _.select folders, (folder)-> !folder.filterOut diff --git a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee index 6c402220a9..59a0748f36 100644 --- a/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee +++ b/services/web/app/coffee/Features/Subscription/LimitationsManager.coffee @@ -4,6 +4,7 @@ User = require("../../models/User").User SubscriptionLocator = require("./SubscriptionLocator") Settings = require("settings-sharelatex") CollaboratorsHandler = require("../Collaborators/CollaboratorsHandler") +CollaboratorsInvitesHandler = require("../Collaborators/CollaboratorsInviteHandler") module.exports = @@ -20,10 +21,12 @@ module.exports = return callback(error) if error? CollaboratorsHandler.getCollaboratorCount project_id, (error, current_number) => return callback(error) if error? - if current_number + x_collaborators <= allowed_number or allowed_number < 0 - callback null, true - else - callback null, false + CollaboratorsInvitesHandler.getInviteCount project_id, (error, invite_count) => + return callback(error) if error? + if current_number + invite_count + x_collaborators <= allowed_number or allowed_number < 0 + callback null, true + else + callback null, false userHasSubscriptionOrIsGroupMember: (user, callback = (err, hasSubscriptionOrIsMember)->) -> @userHasSubscription user, (err, hasSubscription, subscription)=> @@ -41,7 +44,7 @@ module.exports = hasValidSubscription = subscription? and (subscription.recurlySubscription_id? or subscription?.customAccount == true) logger.log user:user, hasValidSubscription:hasValidSubscription, subscription:subscription, "checking if user has subscription" callback err, hasValidSubscription, subscription - + userIsMemberOfGroupSubscription: (user, callback = (error, isMember, subscriptions) ->) -> logger.log user_id: user._id, "checking is user is member of subscription groups" SubscriptionLocator.getMemberSubscriptions user._id, (err, subscriptions = []) -> @@ -65,4 +68,3 @@ getOwnerOfProject = (project_id, callback)-> return callback(error) if error? User.findById project.owner_ref, (error, owner) -> callback(error, owner) - diff --git a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee index 059c5fb02c..2be1fc35c7 100644 --- a/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee +++ b/services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee @@ -29,13 +29,15 @@ module.exports = RecurlyWrapper = RecurlyWrapper.apiRequest({ url: "accounts/#{user._id}" method: "GET" + expect404: true }, (error, response, responseBody) -> if error - if response.statusCode == 404 # actually not an error in this case, just no existing account - cache.userExists = false - return next(null, cache) logger.error {error, user_id: user._id, recurly_token_id}, "error response from recurly while checking account" return next(error) + if response.statusCode == 404 # actually not an error in this case, just no existing account + logger.log {user_id: user._id, recurly_token_id}, "user does not currently exist in recurly, proceed" + cache.userExists = false + return next(null, cache) logger.log {user_id: user._id, recurly_token_id}, "user appears to exist in recurly" RecurlyWrapper._parseAccountXml responseBody, (err, account) -> if err @@ -236,10 +238,14 @@ module.exports = RecurlyWrapper = "Authorization" : "Basic " + new Buffer(Settings.apis.recurly.apiKey).toString("base64") "Accept" : "application/xml" "Content-Type" : "application/xml; charset=utf-8" + expect404 = options.expect404 + delete options.expect404 request options, (error, response, body) -> - unless error? or response.statusCode == 200 or response.statusCode == 201 or response.statusCode == 204 + unless error? or response.statusCode == 200 or response.statusCode == 201 or response.statusCode == 204 or (response.statusCode == 404 and expect404) logger.err err:error, body:body, options:options, statusCode:response?.statusCode, "error returned from recurly" error = "Recurly API returned with status code: #{response.statusCode}" + if response.statusCode == 404 and expect404 + logger.log {url: options.url, method: options.method}, "got 404 response from recurly, expected as valid response" callback(error, response, body) sign : (parameters, callback) -> diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee index ca932ffab3..3d2ba910d0 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionController.coffee @@ -22,6 +22,7 @@ module.exports = SubscriptionController = viewName = "#{viewName}_#{req.query.v}" logger.log viewName:viewName, "showing plans page" GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)-> + return next(err) if err? res.render viewName, title: "plans_and_pricing" plans: plans @@ -71,12 +72,13 @@ module.exports = SubscriptionController = AuthenticationController.getLoggedInUser req, (error, user) => return next(error) if error? LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)-> + return next(err) if err? groupLicenceInviteUrl = SubscriptionDomainHandler.getDomainLicencePage(user) if subscription?.customAccount logger.log user: user, "redirecting to custom account page" res.redirect "/user/subscription/custom_account" else if groupLicenceInviteUrl? and !hasSubOrIsGroupMember - logger.log user:user, "redirecting to group subscription invite page" + logger.log user:user, "redirecting to group subscription invite page" res.redirect groupLicenceInviteUrl else if !hasSubOrIsGroupMember logger.log user: user, "redirecting to plans" @@ -99,7 +101,9 @@ module.exports = SubscriptionController = userCustomSubscriptionPage: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) -> + return next(error) if error? LimitationsManager.userHasSubscriptionOrIsGroupMember user, (err, hasSubOrIsGroupMember, subscription)-> + return next(err) if err? if !subscription? err = new Error("subscription null for custom account, user:#{user?._id}") logger.warn err:err, "subscription is null for custom accounts page" @@ -113,6 +117,7 @@ module.exports = SubscriptionController = AuthenticationController.getLoggedInUser req, (error, user) -> return next(error) if error? LimitationsManager.userHasSubscription user, (err, hasSubscription)-> + return next(err) if err? if !hasSubscription res.redirect "/user/subscription" else @@ -142,54 +147,67 @@ module.exports = SubscriptionController = return res.sendStatus 500 res.sendStatus 201 - successful_subscription: (req, res)-> + successful_subscription: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) => + return next(error) if error? SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel user, (error, subscription) -> + return next(error) if error? res.render "subscriptions/successful_subscription", title: "thank_you" subscription:subscription cancelSubscription: (req, res, next) -> AuthenticationController.getLoggedInUser req, (error, user) -> - logger.log user_id:user._id, "canceling subscription" return next(error) if error? + logger.log user_id:user._id, "canceling subscription" SubscriptionHandler.cancelSubscription user, (err)-> if err? logger.err err:err, user_id:user._id, "something went wrong canceling subscription" + return next(err) res.redirect "/user/subscription" - - updateSubscription: (req, res)-> + + updateSubscription: (req, res, next)-> + _origin = req?.query?.origin || null AuthenticationController.getLoggedInUser req, (error, user) -> return next(error) if error? planCode = req.body.plan_code + if !planCode? + err = new Error('plan_code is not defined') + logger.err {user_id: user._id, err, planCode, origin: _origin, body: req.body}, "[Subscription] error in updateSubscription form" + return next(err) logger.log planCode: planCode, user_id:user._id, "updating subscription" SubscriptionHandler.updateSubscription user, planCode, null, (err)-> if err? logger.err err:err, user_id:user._id, "something went wrong updating subscription" + return next(err) res.redirect "/user/subscription" - reactivateSubscription: (req, res)-> + reactivateSubscription: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) -> - logger.log user_id:user._id, "reactivating subscription" return next(error) if error? + logger.log user_id:user._id, "reactivating subscription" SubscriptionHandler.reactivateSubscription user, (err)-> if err? logger.err err:err, user_id:user._id, "something went wrong reactivating subscription" + return next(err) res.redirect "/user/subscription" - recurlyCallback: (req, res)-> + recurlyCallback: (req, res, next)-> logger.log data: req.body, "received recurly callback" # we only care if a subscription has exipired if req.body? and req.body["expired_subscription_notification"]? recurlySubscription = req.body["expired_subscription_notification"].subscription - SubscriptionHandler.recurlyCallback recurlySubscription, -> + SubscriptionHandler.recurlyCallback recurlySubscription, (err)-> + return next(err) if err? res.sendStatus 200 else res.sendStatus 200 - renderUpgradeToAnnualPlanPage: (req, res)-> + renderUpgradeToAnnualPlanPage: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) -> + return next(error) if error? LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)-> + return next(err) if err? planCode = subscription?.planCode.toLowerCase() if planCode?.indexOf("annual") != -1 planName = "annual" @@ -204,8 +222,9 @@ module.exports = SubscriptionController = title: "Upgrade to annual" planName: planName - processUpgradeToAnnualPlan: (req, res)-> + processUpgradeToAnnualPlan: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) -> + return next(error) if error? {planName} = req.body coupon_code = Settings.coupon_codes.upgradeToAnnualPromo[planName] annualPlanName = "#{planName}-annual" @@ -213,13 +232,14 @@ module.exports = SubscriptionController = SubscriptionHandler.updateSubscription user, annualPlanName, coupon_code, (err)-> if err? logger.err err:err, user_id:user._id, "error updating subscription" - res.sendStatus 500 - else - res.sendStatus 200 + return next(err) + res.sendStatus 200 - extendTrial: (req, res)-> + extendTrial: (req, res, next)-> AuthenticationController.getLoggedInUser req, (error, user) -> + return next(error) if error? LimitationsManager.userHasSubscription user, (err, hasSubscription, subscription)-> + return next(err) if err? SubscriptionHandler.extendTrial subscription, 14, (err)-> if err? res.send 500 diff --git a/services/web/app/coffee/models/ProjectInvite.coffee b/services/web/app/coffee/models/ProjectInvite.coffee index 3349dafa9b..9b9e0cb350 100644 --- a/services/web/app/coffee/models/ProjectInvite.coffee +++ b/services/web/app/coffee/models/ProjectInvite.coffee @@ -1,23 +1,42 @@ mongoose = require 'mongoose' Settings = require 'settings-sharelatex' + Schema = mongoose.Schema ObjectId = Schema.ObjectId -ProjectInviteSchema = new Schema - project_id: ObjectId - from_user_id: ObjectId - privilegeLevel: String - # For existing users - to_user_id: ObjectId - # For non-existant users - hashed_token: String - email: String + +EXPIRY_IN_SECONDS = 60 * 60 * 24 * 30 + +ExpiryDate = () -> + timestamp = new Date() + timestamp.setSeconds(timestamp.getSeconds() + EXPIRY_IN_SECONDS) + return timestamp + + + +ProjectInviteSchema = new Schema( + { + email: String + token: String + sendingUserId: ObjectId + projectId: ObjectId + privileges: String + createdAt: {type: Date, default: Date.now} + expires: {type: Date, default: ExpiryDate, index: {expireAfterSeconds: 10}} + }, + { + collection: 'projectInvites' + } +) + conn = mongoose.createConnection(Settings.mongo.url, server: poolSize: Settings.mongo.poolSize || 10) + ProjectInvite = conn.model('ProjectInvite', ProjectInviteSchema) mongoose.model 'ProjectInvite', ProjectInviteSchema exports.ProjectInvite = ProjectInvite -exports.ProjectInviteSchema = ProjectInviteSchema \ No newline at end of file +exports.ProjectInviteSchema = ProjectInviteSchema +exports.EXPIRY_IN_SECONDS = EXPIRY_IN_SECONDS diff --git a/services/web/app/views/project/editor/chat.jade b/services/web/app/views/project/editor/chat.jade index 86842c36de..47a1752834 100644 --- a/services/web/app/views/project/editor/chat.jade +++ b/services/web/app/views/project/editor/chat.jade @@ -41,11 +41,12 @@ aside.chat( }" ) .arrow(ng-style="{'border-color': 'hsl({{ hue(message.user) }}, 70%, 70%)'}") - p( - mathjax, - ng-repeat="content in message.contents track by $index" - ) - span(ng-bind-html="content | linky:'_blank' | wrapLongWords") + .message-content + p( + mathjax, + ng-repeat="content in message.contents track by $index" + ) + span(ng-bind-html="content | linky:'_blank'") .new-message textarea( diff --git a/services/web/app/views/project/editor/share.jade b/services/web/app/views/project/editor/share.jade index ad3b2bd9ba..fd13ccb240 100644 --- a/services/web/app/views/project/editor/share.jade +++ b/services/web/app/views/project/editor/share.jade @@ -27,12 +27,12 @@ script(type='text/ng-template', id='shareProjectModalTemplate') ) #{translate("make_private")} .row.project-member .col-xs-8 {{ project.owner.email }} - .text-right( + .text-left( ng-class="{'col-xs-3': project.members.length > 0, 'col-xs-4': project.members.length == 0}" ) #{translate("owner")} .row.project-member(ng-repeat="member in project.members") .col-xs-8 {{ member.email }} - .col-xs-3.text-right + .col-xs-3.text-left span(ng-show="member.privileges == 'readAndWrite'") #{translate("can_edit")} span(ng-show="member.privileges == 'readOnly'") #{translate("read_only")} .col-xs-1 @@ -43,6 +43,23 @@ script(type='text/ng-template', id='shareProjectModalTemplate') ng-click="removeMember(member)" ) i.fa.fa-times + .row.project-invite(ng-repeat="invite in project.invites") + .col-xs-8 {{ invite.email }}  + div.small + | #{translate("invite_not_accepted")}.  + a(href="#", ng-click="resendInvite(invite, $event)") #{translate("resend")} + .col-xs-3.text-left + // todo: get invite privileges + span(ng-show="invite.privileges == 'readAndWrite'") #{translate("can_edit")} + span(ng-show="invite.privileges == 'readOnly'") #{translate("read_only")} + .col-xs-1 + a( + href + tooltip="#{translate('revoke_invite')}" + tooltip-placement="bottom" + ng-click="revokeInvite(invite)" + ) + i.fa.fa-times .row.invite-controls form(ng-show="canAddCollaborators") .small #{translate("share_with_your_collabs")} @@ -78,6 +95,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') button.btn.btn-info( type="submit" ng-mousedown="addMembers()" + ng-keyup="$event.keyCode == 13 ? addMembers() : null" ) #{translate("share")} div(ng-hide="canAddCollaborators") p.text-center #{translate("need_to_upgrade_for_more_collabs")}. Also: @@ -123,7 +141,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') .modal-footer-left i.fa.fa-refresh.fa-spin(ng-show="state.inflight") span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")} - button.btn.btn-primary( + button.btn.btn-default( ng-click="done()" ) #{translate("close")} diff --git a/services/web/app/views/project/invite/not-valid.jade b/services/web/app/views/project/invite/not-valid.jade new file mode 100644 index 0000000000..4d6b7f869e --- /dev/null +++ b/services/web/app/views/project/invite/not-valid.jade @@ -0,0 +1,18 @@ +extends ../../layout + +block content + .content.content-alt + .container + .row + .col-md-8.col-md-offset-2 + .card.project-invite-invalid + .page-header.text-centered + h1 #{translate("invite_not_valid")} + .row.text-center + .col-md-12 + p + | #{translate("invite_not_valid_description")}. + .row.text-center.actions + .col-md-12 + a.btn.btn-info(href="/project") #{translate("back_to_your_projects")} + \ No newline at end of file diff --git a/services/web/app/views/project/invite/show.jade b/services/web/app/views/project/invite/show.jade new file mode 100644 index 0000000000..833e75085e --- /dev/null +++ b/services/web/app/views/project/invite/show.jade @@ -0,0 +1,31 @@ +extends ../../layout + +block content + .content.content-alt + .container + .row + .col-md-8.col-md-offset-2 + .card.project-invite-accept + .page-header.text-centered + h1 #{translate("user_wants_you_to_see_project", {username:owner.first_name, projectname:""})} + em + span.project-name #{project.name} + .row.text-center + .col-md-12 + p + | #{translate("accepting_invite_as")}  + em #{user.email} + .row + .col-md-12 + form.form( + name="acceptForm", + method="POST", + action="/project/#{invite.projectId}/invite/#{invite._id}/accept" + ) + input(name='_csrf', type='hidden', value=csrfToken) + input(name='token', type='hidden', value="#{invite.token}") + .form-group.text-center + button.btn.btn-lg.btn-primary(type="submit") + | #{translate("join_project")} + .form-group.text-center + \ No newline at end of file diff --git a/services/web/app/views/project/list/notifications.jade b/services/web/app/views/project/list/notifications.jade index 29f371b724..8dba0852ff 100644 --- a/services/web/app/views/project/list/notifications.jade +++ b/services/web/app/views/project/list/notifications.jade @@ -9,7 +9,9 @@ span(ng-controller="NotificationsController").userNotifications .row(ng-hide="unreadNotification.hide") .col-xs-12 .alert.alert-info - span(ng-bind-html="unreadNotification.html") - button(ng-click="dismiss(unreadNotification)").close.pull-right - span(aria-hidden="true") × - span.sr-only #{translate("close")} + div.notification_inner + span(ng-bind-html="unreadNotification.html").notification_body + span().notification_close + button(ng-click="dismiss(unreadNotification)").close.pull-right + span(aria-hidden="true") × + span.sr-only #{translate("close")} diff --git a/services/web/app/views/subscriptions/edit-billing-details.jade b/services/web/app/views/subscriptions/edit-billing-details.jade index f0b6671bcd..caf204b79d 100644 --- a/services/web/app/views/subscriptions/edit-billing-details.jade +++ b/services/web/app/views/subscriptions/edit-billing-details.jade @@ -19,7 +19,7 @@ block content Recurly.config(!{recurlyConfig}) Recurly.buildBillingInfoUpdateForm({ target : "#billingDetailsForm", - successURL : "#{successURL}?_csrf=#{csrfToken}", + successURL : "#{successURL}?_csrf=#{csrfToken}&origin=editBillingDetails", signature : "!{signature}", accountCode : "#{user.id}" }); diff --git a/services/web/app/views/user/register.jade b/services/web/app/views/user/register.jade index 96db403f1c..5a1196b6a6 100644 --- a/services/web/app/views/user/register.jade +++ b/services/web/app/views/user/register.jade @@ -6,9 +6,13 @@ block content .row .registration_message if sharedProjectData.user_first_name !== undefined - h1 #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:sharedProjectData.project_name})} - div #{translate("join_sl_to_view_project")}. - a(href="/login") #{translate("login_here")} + h1 #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})} + em #{sharedProjectData.project_name} + div + | #{translate("join_sl_to_view_project")}. + div + | #{translate("if_you_are_registered")}, + a(href="/login?redir=#{getReqQueryParam('redir')}") #{translate("login_here")} else if newTemplateData.templateName !== undefined h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})} diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee index e59e35a40c..61cc1b7df7 100644 --- a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee +++ b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogs.coffee @@ -11,6 +11,8 @@ define [ _getRule = (logMessage) -> return rule for rule in ruleset when rule.regexToMatch.test logMessage + seenErrorTypes = {} # keep track of types of errors seen + for entry in parsedLogEntries.all ruleDetails = _getRule entry.message @@ -21,8 +23,20 @@ define [ entry.ruleId = 'hint_' + ruleDetails.regexToMatch.toString().replace(/\s/g, '_').slice(1, -1) if ruleDetails.newMessage? entry.message = entry.message.replace ruleDetails.regexToMatch, ruleDetails.newMessage + # suppress any entries that are known to cascade from previous error types + if ruleDetails.cascadesFrom? + for type in ruleDetails.cascadesFrom + entry.suppressed = true if seenErrorTypes[type] + # record the types of errors seen + if ruleDetails.types? + for type in ruleDetails.types + seenErrorTypes[type] = true entry.humanReadableHint = ruleDetails.humanReadableHint if ruleDetails.humanReadableHint? entry.extraInfoURL = ruleDetails.extraInfoURL if ruleDetails.extraInfoURL? - + + # filter out the suppressed errors (from the array entries in parsedLogEntries) + for key, errors of parsedLogEntries when typeof errors is 'object' and errors.length > 0 + parsedLogEntries[key] = (err for err in errors when not err.suppressed) + return parsedLogEntries diff --git a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee index c61c61506b..164e78ce66 100644 --- a/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee +++ b/services/web/public/coffee/ide/human-readable-logs/HumanReadableLogsRules.coffee @@ -90,6 +90,7 @@ define -> [ """ , ruleId: "hint_mismatched_environment" + types: ['environment'] regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/ newMessage: "Error: environment does not match \\begin{$1} ... \\end{$2}" humanReadableHint: """ @@ -97,10 +98,85 @@ define -> [ """ , ruleId: "hint_mismatched_brackets" + types: ['environment'] regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/ newMessage: "Error: brackets do not match, found '$2' instead of '$1'" humanReadableHint: """ You have used an open bracket without a corresponding close bracket. """ - + , + regexToMatch: /LaTeX Error: Can be used only in preamble/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble" + humanReadableHint: """ + You have used a command in the main body of your document which should be used in the preamble. Make sure that \\documentclass[\u2026]{\u2026} and all \\usepackage{\u2026} commands are written before \\begin{document}. + """ + , + regexToMatch: /Missing \\right inserted/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/Missing_%5Cright_insertede" + humanReadableHint: """ + You have started an expression with a \\left command, but have not included a corresponding \\right command. Make sure that your \\left and \\right commands balance everywhere, or else try using \\Biggl and \\Biggr commands instead as shown here. + """ + , + regexToMatch: /Double superscript/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/Double_superscript" + humanReadableHint: """ + You have written a double superscript incorrectly as a^b^c, or else you have written a prime with a superscript. Remember to include { and } when using multiple superscripts. Try a^{b^c} instead. + """ + , + regexToMatch: /Double subscript/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/Double_subscript" + humanReadableHint: """ + You have written a double subscript incorrectly as a_b_c. Remember to include { and } when using multiple subscripts. Try a_{b_c} instead. + """ + , + regexToMatch: /No \\author given/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_%5Cauthor_given" + humanReadableHint: """ + You have used the \\maketitle command, but have not specified any \\author. To fix this, include an author in your preamble using the \\author{\u2026} command. + """ + , + regexToMatch: /LaTeX Error: Environment .+ undefined/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined" + humanReadableHint: """ + You have created an environment (using \\begin{\u2026} and \\end{\u2026} commands) which is not recognized. Make sure you have included the required package for that environment in your preamble, and that the environment is spelled correctly. + """ + , + regexToMatch: /LaTeX Error: Something's wrong--perhaps a missing \\item/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem" + humanReadableHint: """ + There are no entries found in a list you have created. Make sure you label list entries using the \\item command, and that you have not used a list inside a table. + """ + , + regexToMatch: /Misplaced \\noalign/ + extraInfoURL: "https://www.sharelatex.com/learn/Errors/Misplaced_%5Cnoalign" + humanReadableHint: """ + You have used a \\hline command in the wrong place, probably outside a table. If the \\hline command is written inside a table, try including \\\ before it. + """ + , + ruleId: "hint_mismatched_environment2" + types: ['environment'] + cascadesFrom: ['environment'] + regexToMatch: /Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/ + newMessage: "Error: environments do not match: \\begin{$1} ... \\end{$2}" + humanReadableHint: """ + You have used \\begin{} without a corresponding \\end{}. + """ + , + ruleId: "hint_mismatched_environment3" + types: ['environment'] + cascadesFrom: ['environment'] + regexToMatch: /Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/ + newMessage: "Warning: No matching \\end found for \\begin{$1}" + humanReadableHint: """ + You have used \\begin{} without a corresponding \\end{}. + """ + , + ruleId: "hint_mismatched_environment4" + types: ['environment'] + cascadesFrom: ['environment'] + regexToMatch: /Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/ + newMessage: "Error: found \\end{$1} without a corresponding \\begin{$1}" + humanReadableHint: """ + You have used \\begin{} without a corresponding \\end{}. + """ ] diff --git a/services/web/public/coffee/ide/share/controllers/ShareController.coffee b/services/web/public/coffee/ide/share/controllers/ShareController.coffee index 51a7043191..9fdf4d31e2 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareController.coffee @@ -1,13 +1,30 @@ define [ "base" ], (App) -> - App.controller "ShareController", ["$scope", "$modal", "event_tracking", ($scope, $modal, event_tracking) -> - $scope.openShareProjectModal = () -> - event_tracking.sendMBOnce "ide-open-share-modal-once" + App.controller "ShareController", ["$scope", "$modal", "ide", "projectInvites", "projectMembers", "event_tracking", + ($scope, $modal, ide, projectInvites, projectMembers, event_tracking) -> + $scope.openShareProjectModal = () -> + event_tracking.sendMBOnce "ide-open-share-modal-once" - $modal.open( - templateUrl: "shareProjectModalTemplate" - controller: "ShareProjectModalController" - scope: $scope - ) + $modal.open( + templateUrl: "shareProjectModalTemplate" + controller: "ShareProjectModalController" + scope: $scope + ) + + ide.socket.on 'project:membership:changed', (data) => + if data.members + projectMembers.getMembers() + .success (responseData) => + if responseData.members + $scope.project.members = responseData.members + .error (responseDate) => + console.error "Error fetching members for project" + if data.invites + projectInvites.getInvites() + .success (responseData) => + if responseData.invites + $scope.project.invites = responseData.invites + .error (responseDate) => + console.error "Error fetching invites for project" ] diff --git a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee index 13d5faea9f..409608825e 100644 --- a/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee +++ b/services/web/public/coffee/ide/share/controllers/ShareProjectModalController.coffee @@ -1,7 +1,7 @@ define [ "base" ], (App) -> - App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, $modal, $http) -> + App.controller "ShareProjectModalController", ($scope, $modalInstance, $timeout, projectMembers, projectInvites, $modal, $http) -> $scope.inputs = { privileges: "readAndWrite" contacts: [] @@ -10,6 +10,7 @@ define [ error: null inflight: false startedFreeTrial: false + invites: [] } $modalInstance.opened.then () -> @@ -22,6 +23,8 @@ define [ allowedNoOfMembers = $scope.project.features.collaborators $scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS + window._m = projectMembers + $scope.autocompleteContacts = [] do loadAutocompleteUsers = () -> $http.get "/user/contacts" @@ -38,9 +41,12 @@ define [ else # Must be a group contact.display = contact.name - + getCurrentMemberEmails = () -> - $scope.project.members.map (u) -> u.email + ($scope.project.members || []).map (u) -> u.email + + getCurrentInviteEmails = () -> + ($scope.project.invites || []).map (u) -> u.email $scope.filterAutocompleteUsers = ($query) -> currentMemberEmails = getCurrentMemberEmails() @@ -60,36 +66,47 @@ define [ $scope.inputs.contacts = [] $scope.state.error = null $scope.state.inflight = true - + + if !$scope.project.invites? + $scope.project.invites = [] + currentMemberEmails = getCurrentMemberEmails() + currentInviteEmails = getCurrentInviteEmails() do addNextMember = () -> if members.length == 0 or !$scope.canAddCollaborators $scope.state.inflight = false $scope.$apply() return - + member = members.shift() if !member.type? and member.display in currentMemberEmails # Skip this existing member return addNextMember() - - if member.type == "user" - request = projectMembers.addMember(member.email, $scope.inputs.privileges) + + # NOTE: groups aren't really a thing in ShareLaTeX, partially inherited from DJ + if member.display in currentInviteEmails and inviteId = _.find(($scope.project.invites || []), (invite) -> invite.email == member.display)?._id + request = projectInvites.resendInvite(inviteId) + else if member.type == "user" + request = projectInvites.sendInvite(member.email, $scope.inputs.privileges) else if member.type == "group" request = projectMembers.addGroup(member.id, $scope.inputs.privileges) else # Not an auto-complete object, so email == display - request = projectMembers.addMember(member.display, $scope.inputs.privileges) - + request = projectInvites.sendInvite(member.display, $scope.inputs.privileges) + request .success (data) -> - if data.users? - users = data.users - else if data.user? - users = [data.user] + if data.invite + invite = data.invite + $scope.project.invites.push invite else - users = [] - - $scope.project.members.push users... + if data.users? + users = data.users + else if data.user? + users = [data.user] + else + users = [] + $scope.project.members.push users... + setTimeout () -> # Give $scope a chance to update $scope.canAddCollaborators # with new collaborator information. @@ -98,8 +115,7 @@ define [ .error () -> $scope.state.inflight = false $scope.state.error = true - - + $timeout addMembers, 50 # Give email list a chance to update $scope.removeMember = (member) -> @@ -116,6 +132,33 @@ define [ $scope.state.inflight = false $scope.state.error = "Sorry, something went wrong :(" + $scope.revokeInvite = (invite) -> + $scope.state.error = null + $scope.state.inflight = true + projectInvites + .revokeInvite(invite._id) + .success () -> + $scope.state.inflight = false + index = $scope.project.invites.indexOf(invite) + return if index == -1 + $scope.project.invites.splice(index, 1) + .error () -> + $scope.state.inflight = false + $scope.state.error = "Sorry, something went wrong :(" + + $scope.resendInvite = (invite, event) -> + $scope.state.error = null + $scope.state.inflight = true + projectInvites + .resendInvite(invite._id) + .success () -> + $scope.state.inflight = false + event.target.blur() + .error () -> + $scope.state.inflight = false + $scope.state.error = "Sorry, something went wrong resending the invite :(" + event.target.blur() + $scope.openMakePublicModal = () -> $modal.open { templateUrl: "makePublicModalTemplate" @@ -158,4 +201,4 @@ define [ $scope.cancel = () -> $modalInstance.dismiss() - ] \ No newline at end of file + ] diff --git a/services/web/public/coffee/ide/share/index.coffee b/services/web/public/coffee/ide/share/index.coffee index 545a145be3..13b2bdbcfd 100644 --- a/services/web/public/coffee/ide/share/index.coffee +++ b/services/web/public/coffee/ide/share/index.coffee @@ -2,4 +2,5 @@ define [ "ide/share/controllers/ShareController" "ide/share/controllers/ShareProjectModalController" "ide/share/services/projectMembers" -], () -> \ No newline at end of file + "ide/share/services/projectInvites" +], () -> diff --git a/services/web/public/coffee/ide/share/services/projectInvites.coffee b/services/web/public/coffee/ide/share/services/projectInvites.coffee new file mode 100644 index 0000000000..4c0d30add6 --- /dev/null +++ b/services/web/public/coffee/ide/share/services/projectInvites.coffee @@ -0,0 +1,35 @@ +define [ + "base" +], (App) -> + App.factory "projectInvites", ["ide", "$http", (ide, $http) -> + return { + + sendInvite: (email, privileges) -> + $http.post("/project/#{ide.project_id}/invite", { + email: email + privileges: privileges + _csrf: window.csrfToken + }) + + revokeInvite: (inviteId) -> + $http({ + url: "/project/#{ide.project_id}/invite/#{inviteId}" + method: "DELETE" + headers: + "X-Csrf-Token": window.csrfToken + }) + + resendInvite: (inviteId, privileges) -> + $http.post("/project/#{ide.project_id}/invite/#{inviteId}/resend", { + _csrf: window.csrfToken + }) + + getInvites: () -> + $http.get("/project/#{ide.project_id}/invites", { + json: true + headers: + "X-Csrf-Token": window.csrfToken + }) + + } + ] diff --git a/services/web/public/coffee/ide/share/services/projectMembers.coffee b/services/web/public/coffee/ide/share/services/projectMembers.coffee index a51ea63e99..f1b2c8c3fe 100644 --- a/services/web/public/coffee/ide/share/services/projectMembers.coffee +++ b/services/web/public/coffee/ide/share/services/projectMembers.coffee @@ -11,19 +11,19 @@ define [ "X-Csrf-Token": window.csrfToken }) - addMember: (email, privileges) -> - $http.post("/project/#{ide.project_id}/users", { - email: email - privileges: privileges - _csrf: window.csrfToken - }) - addGroup: (group_id, privileges) -> $http.post("/project/#{ide.project_id}/group", { group_id: group_id privileges: privileges _csrf: window.csrfToken }) - + + getMembers: () -> + $http.get("/project/#{ide.project_id}/members", { + json: true + headers: + "X-Csrf-Token": window.csrfToken + }) + } - ] \ No newline at end of file + ] diff --git a/services/web/public/coffee/main/subscription-dashboard.coffee b/services/web/public/coffee/main/subscription-dashboard.coffee index 63eec0d65a..69d030ed3b 100644 --- a/services/web/public/coffee/main/subscription-dashboard.coffee +++ b/services/web/public/coffee/main/subscription-dashboard.coffee @@ -62,8 +62,7 @@ define [ $scope.inflight = true - - $http.post(SUBSCRIPTION_URL, body) + $http.post("#{SUBSCRIPTION_URL}?origin=confirmChangePlan", body) .success -> location.reload() .error -> @@ -124,7 +123,7 @@ define [ plan_code: 'student' _csrf : window.csrfToken $scope.inflight = true - $http.post(SUBSCRIPTION_URL, body) + $http.post("#{SUBSCRIPTION_URL}?origin=downgradeToStudent", body) .success -> location.reload() .error -> diff --git a/services/web/public/stylesheets/app/editor/chat.less b/services/web/public/stylesheets/app/editor/chat.less index 592d39ecf4..d702a225fe 100644 --- a/services/web/public/stylesheets/app/editor/chat.less +++ b/services/web/public/stylesheets/app/editor/chat.less @@ -47,6 +47,7 @@ } .message-wrapper { margin-left: 50px + @line-height-computed/2; + .name { font-size: 12px; color: @gray-light; @@ -54,12 +55,17 @@ min-height: 16px; } .message { - padding: @line-height-computed / 2; border-left: 3px solid transparent; - position: relative; font-size: 14px; box-shadow: -1px 2px 3px #ddd; border-raduis: @border-radius-base; + position: relative; + + .message-content { + padding: @line-height-computed / 2; + overflow-x: auto; + } + .arrow { right: 100%; top: @line-height-computed / 4; diff --git a/services/web/public/stylesheets/app/editor/share.less b/services/web/public/stylesheets/app/editor/share.less index 77ed2e0df6..cd06a15313 100644 --- a/services/web/public/stylesheets/app/editor/share.less +++ b/services/web/public/stylesheets/app/editor/share.less @@ -6,7 +6,7 @@ font-size: 1rem; } - .project-member, .public-access-level { + .project-member, .project-invite, .public-access-level { padding: (@line-height-computed / 2) 0; border-bottom: 1px solid @gray-lighter; font-size: 14px; @@ -19,7 +19,7 @@ padding-bottom: @line-height-computed; } - .project-member { + .project-member, .project-invite { &:hover { background-color: @gray-lightest; } diff --git a/services/web/public/stylesheets/app/invite.less b/services/web/public/stylesheets/app/invite.less new file mode 100644 index 0000000000..4e6f24303a --- /dev/null +++ b/services/web/public/stylesheets/app/invite.less @@ -0,0 +1,18 @@ +.project-invite-accept { + .page-header { + .project-name { + white-space: pre; + } + } + form { + padding-top: 15px; + } + margin-bottom: 30px; +} + +.project-invite-invalid { + .actions { + padding-top: 15px; + } + margin-bottom: 30px; +} \ No newline at end of file diff --git a/services/web/public/stylesheets/app/project-list.less b/services/web/public/stylesheets/app/project-list.less index 53c780dbe4..d3131f136c 100644 --- a/services/web/public/stylesheets/app/project-list.less +++ b/services/web/public/stylesheets/app/project-list.less @@ -32,10 +32,24 @@ ul { margin-bottom:0px; } - .alert { - .box-shadow(2px 4px 6px rgba(0, 0, 0, 0.25)); + .notification_entry { + .alert { + .box-shadow(2px 4px 6px rgba(0, 0, 0, 0.25)); + .notification_inner { + display: table-row; + .notification_body { + display: table-cell; + width: 99%; + padding-right: 15px; + vertical-align: middle; + } + .notification_close { + display: table-cell; + vertical-align: middle; + } + } + } } - } ul.folders-menu { diff --git a/services/web/public/stylesheets/style.less b/services/web/public/stylesheets/style.less index 2abb57388f..03df27569b 100755 --- a/services/web/public/stylesheets/style.less +++ b/services/web/public/stylesheets/style.less @@ -77,8 +77,8 @@ @import "app/contact-us.less"; @import "app/subscription.less"; @import "app/sprites.less"; +@import "app/invite.less"; @import "../js/libs/pdfListView/TextLayer.css"; @import "../js/libs/pdfListView/AnnotationsLayer.css"; @import "../js/libs/pdfListView/HighlightsLayer.css"; - diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee index 32de9ebe0a..35ae828708 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsControllerTests.coffee @@ -18,6 +18,7 @@ describe "CollaboratorsController", -> '../Subscription/LimitationsManager' : @LimitationsManager = {} '../Project/ProjectEditorHandler' : @ProjectEditorHandler = {} '../User/UserGetter': @UserGetter = {} + 'logger-sharelatex': @logger = {err: sinon.stub(), erro: sinon.stub(), log: sinon.stub()} @res = new MockResponse() @req = new MockRequest() @@ -73,10 +74,10 @@ describe "CollaboratorsController", -> beforeEach -> @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false) @CollaboratorsController.addUserToProject @req, @res, @next - + it "should not add the user to the project", -> @CollaboratorsHandler.addEmailToProject.called.should.equal false - + it "should not emit a userAddedToProject event", -> @EditorRealTimeController.emitToRoom.called.should.equal false @@ -93,17 +94,17 @@ describe "CollaboratorsController", -> @res.status = sinon.stub().returns @res @res.send = sinon.stub() @CollaboratorsController.addUserToProject @req, @res, @next - + it "should not add the user to the project", -> @CollaboratorsHandler.addEmailToProject.called.should.equal false - + it "should not emit a userAddedToProject event", -> @EditorRealTimeController.emitToRoom.called.should.equal false it "should return a 400 response", -> @res.status.calledWith(400).should.equal true @res.send.calledWith("invalid email address").should.equal true - + describe "removeUserFromProject", -> beforeEach -> @req.params = @@ -113,20 +114,23 @@ describe "CollaboratorsController", -> @EditorRealTimeController.emitToRoom = sinon.stub() @CollaboratorsHandler.removeUserFromProject = sinon.stub().callsArg(2) @CollaboratorsController.removeUserFromProject @req, @res - + it "should from the user from the project", -> @CollaboratorsHandler.removeUserFromProject .calledWith(@project_id, @user_id) .should.equal true - + it "should emit a userRemovedFromProject event to the proejct", -> @EditorRealTimeController.emitToRoom .calledWith(@project_id, 'userRemovedFromProject', @user_id) .should.equal true - + it "should send the back a success response", -> @res.sendStatus.calledWith(204).should.equal true + it 'should have called emitToRoom', -> + @EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true + describe "removeSelfFromProject", -> beforeEach -> @req.session = @@ -141,7 +145,7 @@ describe "CollaboratorsController", -> @CollaboratorsHandler.removeUserFromProject .calledWith(@project_id, @user_id) .should.equal true - + it "should emit a userRemovedFromProject event to the proejct", -> @EditorRealTimeController.emitToRoom .calledWith(@project_id, 'userRemovedFromProject', @user_id) @@ -150,3 +154,37 @@ describe "CollaboratorsController", -> it "should return a success code", -> @res.sendStatus.calledWith(204).should.equal true + describe 'getAllMembers', -> + beforeEach -> + @req.session = + user: _id: @user_id = "user-id-123" + @req.params = Project_id: @project_id + @res.json = sinon.stub() + @next = sinon.stub() + @members = [{a: 1}] + @CollaboratorsHandler.getAllMembers = sinon.stub().callsArgWith(1, null, @members) + @CollaboratorsController.getAllMembers(@req, @res, @next) + + it 'should not produce an error', -> + @next.callCount.should.equal 0 + + it 'should produce a json response', -> + @res.json.callCount.should.equal 1 + @res.json.calledWith({members: @members}).should.equal true + + it 'should call CollaboratorsHandler.getAllMembers', -> + @CollaboratorsHandler.getAllMembers.callCount.should.equal 1 + + describe 'when CollaboratorsHandler.getAllMembers produces an error', -> + beforeEach -> + @res.json = sinon.stub() + @next = sinon.stub() + @CollaboratorsHandler.getAllMembers = sinon.stub().callsArgWith(1, new Error('woops')) + @CollaboratorsController.getAllMembers(@req, @res, @next) + + it 'should produce an error', -> + @next.callCount.should.equal 1 + @next.firstCall.args[0].should.be.instanceof Error + + it 'should not produce a json response', -> + @res.json.callCount.should.equal 0 diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee index 37e39aaacb..ff6ca6de67 100644 --- a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsHandlerTests.coffee @@ -18,13 +18,14 @@ describe "CollaboratorsHandler", -> "../Project/ProjectEntityHandler": @ProjectEntityHandler = {} "./CollaboratorsEmailHandler": @CollaboratorsEmailHandler = {} "../Errors/Errors": Errors + "../Project/ProjectEditorHandler": @ProjectEditorHandler = {} @project_id = "mock-project-id" @user_id = "mock-user-id" @adding_user_id = "adding-user-id" @email = "joe@sharelatex.com" @callback = sinon.stub() - + describe "getMemberIdsWithPrivilegeLevels", -> describe "with project", -> beforeEach -> @@ -46,16 +47,16 @@ describe "CollaboratorsHandler", -> { id: "read-write-ref-2", privilegeLevel: "readAndWrite" } ]) .should.equal true - + describe "with a missing project", -> beforeEach -> @Project.findOne = sinon.stub().yields(null, null) - + it "should return a NotFoundError", (done) -> @CollaboratorHandler.getMemberIdsWithPrivilegeLevels @project_id, (error) -> error.should.be.instanceof Errors.NotFoundError done() - + describe "getMemberIds", -> beforeEach -> @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() @@ -68,7 +69,7 @@ describe "CollaboratorsHandler", -> @callback .calledWith(null, ["member-id-1", "member-id-2"]) .should.equal true - + describe "getMembersWithPrivilegeLevels", -> beforeEach -> @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() @@ -86,7 +87,7 @@ describe "CollaboratorsHandler", -> @UserGetter.getUser.withArgs("read-write-ref-2").yields(null, { _id: "read-write-ref-2" }) @UserGetter.getUser.withArgs("doesnt-exist").yields(null, null) @CollaboratorHandler.getMembersWithPrivilegeLevels @project_id, @callback - + it "should return an array of members with their privilege levels", -> @callback .calledWith(null, [ @@ -96,7 +97,7 @@ describe "CollaboratorsHandler", -> { user: { _id: "read-write-ref-2" }, privilegeLevel: "readAndWrite" } ]) .should.equal true - + describe "getMemberIdPrivilegeLevel", -> beforeEach -> @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() @@ -116,7 +117,7 @@ describe "CollaboratorsHandler", -> @CollaboratorHandler.getMemberIdPrivilegeLevel "member-id-3", @project_id, (error, level) -> expect(level).to.equal false done() - + describe "isUserMemberOfProject", -> beforeEach -> @CollaboratorHandler.getMemberIdsWithPrivilegeLevels = sinon.stub() @@ -128,7 +129,7 @@ describe "CollaboratorsHandler", -> { id: @user_id, privilegeLevel: "readAndWrite" } ]) @CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback - + it "should return true and the privilegeLevel", -> @callback .calledWith(null, true, "readAndWrite") @@ -140,12 +141,12 @@ describe "CollaboratorsHandler", -> { id: "not-the-user", privilegeLevel: "readOnly" } ]) @CollaboratorHandler.isUserMemberOfProject @user_id, @project_id, @callback - + it "should return false", -> @callback .calledWith(null, false, null) .should.equal true - + describe "getProjectsUserIsCollaboratorOf", -> beforeEach -> @fields = "mock fields" @@ -153,7 +154,7 @@ describe "CollaboratorsHandler", -> @Project.find.withArgs({collaberator_refs:@user_id}, @fields).yields(null, ["mock-read-write-project-1", "mock-read-write-project-2"]) @Project.find.withArgs({readOnly_refs:@user_id}, @fields).yields(null, ["mock-read-only-project-1", "mock-read-only-project-2"]) @CollaboratorHandler.getProjectsUserIsCollaboratorOf @user_id, @fields, @callback - + it "should call the callback with the projects", -> @callback .calledWith(null, ["mock-read-write-project-1", "mock-read-write-project-2"], ["mock-read-only-project-1", "mock-read-only-project-2"]) @@ -172,17 +173,15 @@ describe "CollaboratorsHandler", -> "$pull":{collaberator_refs:@user_id, readOnly_refs:@user_id} }) .should.equal true - + describe "addUserToProject", -> beforeEach -> @Project.update = sinon.stub().callsArg(2) @Project.findOne = sinon.stub().callsArgWith(2, null, @project = {}) @ProjectEntityHandler.flushProjectToThirdPartyDataStore = sinon.stub().callsArg(1) @CollaboratorHandler.addEmailToProject = sinon.stub().callsArgWith(4, null, @user_id) - @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user = { _id: @user_id, email: @email }) - @CollaboratorsEmailHandler.notifyUserOfProjectShare = sinon.stub() @ContactManager.addContact = sinon.stub() - + describe "as readOnly", -> beforeEach -> @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readOnly", @callback @@ -195,22 +194,17 @@ describe "CollaboratorsHandler", -> "$addToSet":{ readOnly_refs: @user_id } }) .should.equal true - + it "should flush the project to the TPDS", -> @ProjectEntityHandler.flushProjectToThirdPartyDataStore .calledWith(@project_id) .should.equal true - it "should send an email to the shared-with user", -> - @CollaboratorsEmailHandler.notifyUserOfProjectShare - .calledWith(@project_id, @email) - .should.equal true - it "should add the user as a contact for the adding user", -> @ContactManager.addContact .calledWith(@adding_user_id, @user_id) .should.equal true - + describe "as readAndWrite", -> beforeEach -> @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "readAndWrite", @callback @@ -223,19 +217,19 @@ describe "CollaboratorsHandler", -> "$addToSet":{ collaberator_refs: @user_id } }) .should.equal true - + it "should flush the project to the TPDS", -> @ProjectEntityHandler.flushProjectToThirdPartyDataStore .calledWith(@project_id) .should.equal true - + describe "with invalid privilegeLevel", -> beforeEach -> @CollaboratorHandler.addUserIdToProject @project_id, @adding_user_id, @user_id, "notValid", @callback it "should call the callback with an error", -> @callback.calledWith(new Error()).should.equal true - + describe "when user already exists as a collaborator", -> beforeEach -> @project.collaberator_refs = [@user_id] @@ -243,7 +237,7 @@ describe "CollaboratorsHandler", -> it "should not add the user again", -> @Project.update.called.should.equal false - + describe "addEmailToProject", -> beforeEach -> @UserCreator.getUserOrCreateHoldingAccount = sinon.stub().callsArgWith(1, null, @user = {_id: @user_id}) @@ -252,27 +246,27 @@ describe "CollaboratorsHandler", -> describe "with a valid email", -> beforeEach -> @CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, (@email = "Joe@example.com"), (@privilegeLevel = "readAndWrite"), @callback - + it "should get the user with the lowercased email", -> @UserCreator.getUserOrCreateHoldingAccount .calledWith(@email.toLowerCase()) .should.equal true - + it "should add the user to the project by id", -> @CollaboratorHandler.addUserIdToProject .calledWith(@project_id, @adding_user_id, @user_id, @privilegeLevel) .should.equal true - + it "should return the callback with the user_id", -> @callback.calledWith(null, @user_id).should.equal true - + describe "with an invalid email", -> beforeEach -> @CollaboratorHandler.addEmailToProject @project_id, @adding_user_id, "not-and-email", (@privilegeLevel = "readAndWrite"), @callback - + it "should call the callback with an error", -> @callback.calledWith(new Error()).should.equal true - + it "should not add any users to the proejct", -> @CollaboratorHandler.addUserIdToProject.called.should.equal false @@ -286,9 +280,67 @@ describe "CollaboratorsHandler", -> ) @CollaboratorHandler.removeUserFromProject = sinon.stub().yields() @CollaboratorHandler.removeUserFromAllProjets @user_id, done - + it "should remove the user from each project", -> for project_id in ["read-and-write-0", "read-and-write-1", "read-only-0", "read-only-1"] @CollaboratorHandler.removeUserFromProject .calledWith(project_id, @user_id) - .should.equal true \ No newline at end of file + .should.equal true + + describe 'getAllMembers', -> + + beforeEach -> + @owning_user = {_id: 'owner-id', email: 'owner@example.com', features: {a: 1}} + @readwrite_user = {_id: 'readwrite-id', email: 'readwrite@example.com'} + @members = [ + {user: @owning_user, privilegeLevel: "owner"}, + {user: @readwrite_user, privilegeLevel: "readAndWrite"} + ] + @CollaboratorHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, null, @members) + @ProjectEditorHandler.buildOwnerAndMembersViews = sinon.stub().returns(@views = { + owner: @owning_user, + ownerFeatures: @owning_user.features, + members: [ {_id: @readwrite_user._id, email: @readwrite_user.email} ] + }) + @callback = sinon.stub() + @CollaboratorHandler.getAllMembers @project_id, @callback + + it 'should not produce an error', -> + @callback.callCount.should.equal 1 + expect(@callback.firstCall.args[0]).to.equal null + + it 'should produce a list of members', -> + @callback.callCount.should.equal 1 + expect(@callback.firstCall.args[1]).to.deep.equal @views.members + + it 'should call getMembersWithPrivileges', -> + @CollaboratorHandler.getMembersWithPrivilegeLevels.callCount.should.equal 1 + @CollaboratorHandler.getMembersWithPrivilegeLevels.firstCall.args[0].should.equal @project_id + + it 'should call ProjectEditorHandler.buildOwnerAndMembersViews', -> + @ProjectEditorHandler.buildOwnerAndMembersViews.callCount.should.equal 1 + @ProjectEditorHandler.buildOwnerAndMembersViews.firstCall.args[0].should.equal @members + + describe 'when getMembersWithPrivileges produces an error', -> + + beforeEach -> + @CollaboratorHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, new Error('woops')) + @ProjectEditorHandler.buildOwnerAndMembersViews = sinon.stub().returns(@views = { + owner: @owning_user, + ownerFeatures: @owning_user.features, + members: [ {_id: @readwrite_user._id, email: @readwrite_user.email} ] + }) + @callback = sinon.stub() + @CollaboratorHandler.getAllMembers @project_id, @callback + + it 'should produce an error', -> + @callback.callCount.should.equal 1 + expect(@callback.firstCall.args[0]).to.not.equal null + expect(@callback.firstCall.args[0]).to.be.instanceof Error + + it 'should call getMembersWithPrivileges', -> + @CollaboratorHandler.getMembersWithPrivilegeLevels.callCount.should.equal 1 + @CollaboratorHandler.getMembersWithPrivilegeLevels.firstCall.args[0].should.equal @project_id + + it 'should not call ProjectEditorHandler.buildOwnerAndMembersViews', -> + @ProjectEditorHandler.buildOwnerAndMembersViews.callCount.should.equal 0 diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee new file mode 100644 index 0000000000..346ac3a3c1 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteControllerTests.coffee @@ -0,0 +1,574 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/Features/Collaborators/CollaboratorsInviteController.js" +SandboxedModule = require('sandboxed-module') +events = require "events" +MockRequest = require "../helpers/MockRequest" +MockResponse = require "../helpers/MockResponse" +ObjectId = require("mongojs").ObjectId + +describe "CollaboratorsInviteController", -> + beforeEach -> + @CollaboratorsInviteController = SandboxedModule.require modulePath, requires: + "../Project/ProjectGetter": @ProjectGetter = {} + '../Subscription/LimitationsManager' : @LimitationsManager = {} + '../User/UserGetter': @UserGetter = {getUser: sinon.stub()} + "./CollaboratorsHandler": @CollaboratorsHandler = {} + "./CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} + 'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()} + "../Editor/EditorRealTimeController": @EditorRealTimeController = {emitToRoom: sinon.stub()} + "../Notifications/NotificationsBuilder": @NotificationsBuilder = {} + @res = new MockResponse() + @req = new MockRequest() + + @project_id = "project-id-123" + @callback = sinon.stub() + + describe 'getAllInvites', -> + + beforeEach -> + @fakeInvites = [ + {_id: ObjectId(), one: 1}, + {_id: ObjectId(), two: 2} + ] + @req.params = + Project_id: @project_id + @res.json = sinon.stub() + @next = sinon.stub() + + describe 'when all goes well', -> + + beforeEach -> + @CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, null, @fakeInvites) + @CollaboratorsInviteController.getAllInvites @req, @res, @next + + it 'should not produce an error', -> + @next.callCount.should.equal 0 + + it 'should produce a list of invite objects', -> + @res.json.callCount.should.equal 1 + @res.json.calledWith({invites: @fakeInvites}).should.equal true + + it 'should have called CollaboratorsInviteHandler.getAllInvites', -> + @CollaboratorsInviteHandler.getAllInvites.callCount.should.equal 1 + @CollaboratorsInviteHandler.getAllInvites.calledWith(@project_id).should.equal true + + describe 'when CollaboratorsInviteHandler.getAllInvites produces an error', -> + + beforeEach -> + @CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, new Error('woops')) + @CollaboratorsInviteController.getAllInvites @req, @res, @next + + it 'should produce an error', -> + @next.callCount.should.equal 1 + @next.firstCall.args[0].should.be.instanceof Error + + describe 'inviteToProject', -> + + beforeEach -> + @targetEmail = "user@example.com" + @req.params = + Project_id: @project_id + @current_user = + _id: @current_user_id = "current-user-id" + @req.session = + user: @current_user + @req.body = + email: @targetEmail + privileges: @privileges = "readAndWrite" + @res.json = sinon.stub() + @res.sendStatus = sinon.stub() + @invite = { + _id: ObjectId(), + token: "htnseuthaouse", + sendingUserId: @current_user_id, + projectId: @targetEmail, + targetEmail: 'user@example.com' + createdAt: new Date(), + } + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) + @CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, null, @invite) + @callback = sinon.stub() + @next = sinon.stub() + + describe 'when all goes well', -> + + beforeEach -> + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, true) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + it 'should produce json response', -> + @res.json.callCount.should.equal 1 + ({invite: @invite}).should.deep.equal(@res.json.firstCall.args[0]) + + it 'should have called canAddXCollaborators', -> + @LimitationsManager.canAddXCollaborators.callCount.should.equal 1 + @LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true + + it 'should have called inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1 + @CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true + + it 'should have called emitToRoom', -> + @EditorRealTimeController.emitToRoom.callCount.should.equal 1 + @EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true + + describe 'when the user is not allowed to add more collaborators', -> + + beforeEach -> + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, null, false) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + it 'should produce json response without an invite', -> + @res.json.callCount.should.equal 1 + ({invite: null}).should.deep.equal(@res.json.firstCall.args[0]) + + it 'should not have called inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 + + describe 'when canAddXCollaborators produces an error', -> + + beforeEach -> + @err = new Error('woops') + @LimitationsManager.canAddXCollaborators = sinon.stub().callsArgWith(2, @err) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + it 'should call next with an error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should not have called inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 0 + + describe 'when inviteToProject produces an error', -> + + beforeEach -> + @err = new Error('woops') + @CollaboratorsInviteHandler.inviteToProject = sinon.stub().callsArgWith(4, @err) + @CollaboratorsInviteController.inviteToProject @req, @res, @next + + it 'should call next with an error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should have called canAddXCollaborators', -> + @LimitationsManager.canAddXCollaborators.callCount.should.equal 1 + @LimitationsManager.canAddXCollaborators.calledWith(@project_id).should.equal true + + it 'should have called inviteToProject', -> + @CollaboratorsInviteHandler.inviteToProject.callCount.should.equal 1 + @CollaboratorsInviteHandler.inviteToProject.calledWith(@project_id,@current_user,@targetEmail,@privileges).should.equal true + + describe "viewInvite", -> + + beforeEach -> + @token = "some-opaque-token" + @req.params = + Project_id: @project_id + token: @token + @req.session = + user: _id: @current_user_id = "current-user-id" + @res.render = sinon.stub() + @res.redirect = sinon.stub() + @res.sendStatus = sinon.stub() + @invite = { + _id: ObjectId(), + token: @token, + sendingUserId: ObjectId(), + projectId: @project_id, + targetEmail: 'user@example.com' + createdAt: new Date(), + } + @fakeProject = + _id: @project_id + name: "some project" + owner_ref: @invite.sendingUserId + collaberator_refs: [] + readOnly_refs: [] + @owner = + _id: @fakeProject.owner_ref + first_name: "John" + last_name: "Doe" + email: "john@example.com" + + @CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, null, false, null) + @CollaboratorsInviteHandler.getInviteByToken = sinon.stub().callsArgWith(2, null, @invite) + @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject) + @UserGetter.getUser.callsArgWith(2, null, @owner) + + @callback = sinon.stub() + @next = sinon.stub() + + describe 'when the token is valid', -> + + beforeEach -> + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should render the view template', -> + @res.render.callCount.should.equal 1 + @res.render.calledWith('project/invite/show').should.equal true + + it 'should not call next', -> + @next.callCount.should.equal 0 + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + @CollaboratorsInviteHandler.getInviteByToken.calledWith(@fakeProject._id, @invite.token).should.equal true + + it 'should call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true + + it 'should call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 1 + @ProjectGetter.getProject.calledWith(@project_id).should.equal true + + describe 'when user is already a member of the project', -> + + beforeEach -> + @CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, null, true, null) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should redirect to the project page', -> + @res.redirect.callCount.should.equal 1 + @res.redirect.calledWith("/project/#{@project_id}").should.equal true + + it 'should not call next with an error', -> + @next.callCount.should.equal 0 + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should not call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 0 + + it 'should not call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 0 + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when isUserMemberOfProject produces an error', -> + + beforeEach -> + @CollaboratorsHandler.isUserMemberOfProject = sinon.stub().callsArgWith(2, new Error('woops')) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should call next with an error', -> + @next.callCount.should.equal 1 + expect(@next.firstCall.args[0]).to.be.instanceof Error + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should not call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 0 + + it 'should not call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 0 + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when the getInviteByToken produces an error', -> + + beforeEach -> + @err = new Error('woops') + @CollaboratorsInviteHandler.getInviteByToken.callsArgWith(2, @err) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should call next with the error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should not call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 0 + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when the getInviteByToken does not produce an invite', -> + + beforeEach -> + @CollaboratorsInviteHandler.getInviteByToken.callsArgWith(2, null, null) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should render the not-valid view template', -> + @res.render.callCount.should.equal 1 + @res.render.calledWith('project/invite/not-valid').should.equal true + + it 'should not call next', -> + @next.callCount.should.equal 0 + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should not call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 0 + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when User.getUser produces an error', -> + + beforeEach -> + @UserGetter.getUser.callsArgWith(2, new Error('woops')) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should produce an error', -> + @next.callCount.should.equal 1 + expect(@next.firstCall.args[0]).to.be.instanceof Error + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + + it 'should call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when User.getUser does not find a user', -> + + beforeEach -> + @UserGetter.getUser.callsArgWith(2, null, null) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should render the not-valid view template', -> + @res.render.callCount.should.equal 1 + @res.render.calledWith('project/invite/not-valid').should.equal true + + it 'should not call next', -> + @next.callCount.should.equal 0 + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + + it 'should call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true + + it 'should not call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 0 + + describe 'when getProject produces an error', -> + + beforeEach -> + @ProjectGetter.getProject.callsArgWith(2, new Error('woops')) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should produce an error', -> + @next.callCount.should.equal 1 + expect(@next.firstCall.args[0]).to.be.instanceof Error + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + + it 'should call User.getUser', -> + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true + + it 'should call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 1 + + describe 'when Project.getUser does not find a user', -> + + beforeEach -> + @ProjectGetter.getProject.callsArgWith(2, null, null) + @CollaboratorsInviteController.viewInvite @req, @res, @next + + it 'should render the not-valid view template', -> + @res.render.callCount.should.equal 1 + @res.render.calledWith('project/invite/not-valid').should.equal true + + it 'should not call next', -> + @next.callCount.should.equal 0 + + it 'should call CollaboratorsHandler.isUserMemberOfProject', -> + @CollaboratorsHandler.isUserMemberOfProject.callCount.should.equal 1 + @CollaboratorsHandler.isUserMemberOfProject.calledWith(@current_user_id, @project_id).should.equal true + + it 'should call getInviteByToken', -> + @CollaboratorsInviteHandler.getInviteByToken.callCount.should.equal 1 + + it 'should call getUser', -> + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({_id: @fakeProject.owner_ref}).should.equal true + + it 'should call ProjectGetter.getProject', -> + @ProjectGetter.getProject.callCount.should.equal 1 + + describe "resendInvite", -> + + beforeEach -> + @req.params = + Project_id: @project_id + invite_id: @invite_id = "thuseoautoh" + @req.session = + user: _id: @current_user_id = "current-user-id" + @res.render = sinon.stub() + @res.sendStatus = sinon.stub() + @CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, null) + @callback = sinon.stub() + @next = sinon.stub() + + describe 'when resendInvite does not produce an error', -> + + beforeEach -> + @CollaboratorsInviteController.resendInvite @req, @res, @next + + it 'should produce a 201 response', -> + @res.sendStatus.callCount.should.equal 1 + @res.sendStatus.calledWith(201).should.equal true + + it 'should have called resendInvite', -> + @CollaboratorsInviteHandler.resendInvite.callCount.should.equal 1 + + describe 'when resendInvite produces an error', -> + + beforeEach -> + @err = new Error('woops') + @CollaboratorsInviteHandler.resendInvite = sinon.stub().callsArgWith(3, @err) + @CollaboratorsInviteController.resendInvite @req, @res, @next + + it 'should not produce a 201 response', -> + @res.sendStatus.callCount.should.equal 0 + + it 'should call next with the error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should have called resendInvite', -> + @CollaboratorsInviteHandler.resendInvite.callCount.should.equal 1 + + describe "revokeInvite", -> + + beforeEach -> + @req.params = + Project_id: @project_id + invite_id: @invite_id = "thuseoautoh" + @current_user = + _id: @current_user_id = "current-user-id" + @req.session = + user: @current_user + @res.render = sinon.stub() + @res.sendStatus = sinon.stub() + @CollaboratorsInviteHandler.revokeInvite = sinon.stub().callsArgWith(2, null) + @callback = sinon.stub() + @next = sinon.stub() + + describe 'when revokeInvite does not produce an error', -> + + beforeEach -> + @CollaboratorsInviteController.revokeInvite @req, @res, @next + + it 'should produce a 201 response', -> + @res.sendStatus.callCount.should.equal 1 + @res.sendStatus.calledWith(201).should.equal true + + it 'should have called revokeInvite', -> + @CollaboratorsInviteHandler.revokeInvite.callCount.should.equal 1 + + it 'should have called emitToRoom', -> + @EditorRealTimeController.emitToRoom.callCount.should.equal 1 + @EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true + + describe 'when revokeInvite produces an error', -> + + beforeEach -> + @err = new Error('woops') + @CollaboratorsInviteHandler.revokeInvite = sinon.stub().callsArgWith(2, @err) + @CollaboratorsInviteController.revokeInvite @req, @res, @next + + it 'should not produce a 201 response', -> + @res.sendStatus.callCount.should.equal 0 + + it 'should call next with the error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should have called revokeInvite', -> + @CollaboratorsInviteHandler.revokeInvite.callCount.should.equal 1 + + describe "acceptInvite", -> + + beforeEach -> + @req.params = + Project_id: @project_id + invite_id: @invite_id = "thuseoautoh" + @req.session = + user: _id: @current_user_id = "current-user-id" + @req.body = + token: "thsueothaueotauahsuet" + @res.render = sinon.stub() + @res.redirect = sinon.stub() + @CollaboratorsInviteHandler.acceptInvite = sinon.stub().callsArgWith(4, null) + @callback = sinon.stub() + @next = sinon.stub() + + describe 'when acceptInvite does not produce an error', -> + + beforeEach -> + @CollaboratorsInviteController.acceptInvite @req, @res, @next + + it 'should redirect to project page', () -> + @res.redirect.callCount.should.equal 1 + @res.redirect.calledWith("/project/#{@project_id}").should.equal true + + it 'should have called acceptInvite', -> + @CollaboratorsInviteHandler.acceptInvite.callCount.should.equal 1 + + it 'should have called emitToRoom', -> + @EditorRealTimeController.emitToRoom.callCount.should.equal 1 + @EditorRealTimeController.emitToRoom.calledWith(@project_id, 'project:membership:changed').should.equal true + + describe 'when revokeInvite produces an error', -> + + beforeEach -> + @err = new Error('woops') + @CollaboratorsInviteHandler.acceptInvite = sinon.stub().callsArgWith(4, @err) + @CollaboratorsInviteController.acceptInvite @req, @res, @next + + it 'should not redirect to project page', -> + @res.redirect.callCount.should.equal 0 + + it 'should call next with the error', -> + @next.callCount.should.equal 1 + @next.calledWith(@err).should.equal true + + it 'should have called acceptInvite', -> + @CollaboratorsInviteHandler.acceptInvite.callCount.should.equal 1 diff --git a/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee new file mode 100644 index 0000000000..d4ae9a4229 --- /dev/null +++ b/services/web/test/UnitTests/coffee/Collaborators/CollaboratorsInviteHandlerTests.coffee @@ -0,0 +1,723 @@ +sinon = require('sinon') +chai = require('chai') +should = chai.should() +expect = chai.expect +modulePath = "../../../../app/js/Features/Collaborators/CollaboratorsInviteHandler.js" +SandboxedModule = require('sandboxed-module') +events = require "events" +ObjectId = require("mongojs").ObjectId +Crypto = require('crypto') + +describe "CollaboratorsInviteHandler", -> + beforeEach -> + @ProjectInvite = class ProjectInvite + constructor: (options={}) -> + this._id = ObjectId() + for k,v of options + this[k] = v + this + save: sinon.stub() + @findOne: sinon.stub() + @find: sinon.stub() + @remove: sinon.stub() + @count: sinon.stub() + @Crypto = Crypto + @CollaboratorsInviteHandler = SandboxedModule.require modulePath, requires: + 'settings-sharelatex': @settings = {} + '../../models/ProjectInvite': {ProjectInvite: @ProjectInvite} + 'logger-sharelatex': @logger = {err: sinon.stub(), error: sinon.stub(), log: sinon.stub()} + './CollaboratorsEmailHandler': @CollaboratorsEmailHandler = {} + "./CollaboratorsHandler": @CollaboratorsHandler = {addUserIdToProject: sinon.stub()} + '../User/UserGetter': @UserGetter = {getUser: sinon.stub()} + "../Project/ProjectGetter": @ProjectGetter = {} + "../Notifications/NotificationsBuilder": @NotificationsBuilder = {} + 'crypto': @Crypto + + @projectId = ObjectId() + @sendingUserId = ObjectId() + @sendingUser = + _id: @sendingUserId + name: "Bob" + @email = "user@example.com" + @userId = ObjectId() + @user = + _id: @userId + email: 'someone@example.com' + @inviteId = ObjectId() + @token = 'hnhteaosuhtaeosuahs' + @privileges = "readAndWrite" + @fakeInvite = + _id: @inviteId + email: @email + token: @token + sendingUserId: @sendingUserId + projectId: @projectId + privileges: @privileges + createdAt: new Date() + + describe 'getInviteCount', -> + + beforeEach -> + @ProjectInvite.count.callsArgWith(1, null, 2) + @call = (callback) => + @CollaboratorsInviteHandler.getInviteCount @projectId, callback + + it 'should not produce an error', (done) -> + @call (err, invites) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should produce the count of documents', (done) -> + @call (err, count) => + expect(count).to.equal 2 + done() + + describe 'when model.count produces an error', -> + + beforeEach -> + @ProjectInvite.count.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, count) => + expect(err).to.be.instanceof Error + done() + + describe 'getAllInvites', -> + + beforeEach -> + @fakeInvites = [ + {_id: ObjectId(), one: 1}, + {_id: ObjectId(), two: 2} + ] + @ProjectInvite.find.callsArgWith(1, null, @fakeInvites) + @call = (callback) => + @CollaboratorsInviteHandler.getAllInvites @projectId, callback + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err, invites) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should produce a list of invite objects', (done) -> + @call (err, invites) => + expect(invites).to.not.be.oneOf [null, undefined] + expect(invites).to.deep.equal @fakeInvites + done() + + it 'should have called ProjectInvite.find', (done) -> + @call (err, invites) => + @ProjectInvite.find.callCount.should.equal 1 + @ProjectInvite.find.calledWith({projectId: @projectId}).should.equal true + done() + + describe 'when ProjectInvite.find produces an error', -> + + beforeEach -> + @ProjectInvite.find.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, invites) => + expect(err).to.be.instanceof Error + done() + + describe 'inviteToProject', -> + + beforeEach -> + @ProjectInvite::save = sinon.spy (cb) -> cb(null, this) + @randomBytesSpy = sinon.spy(@Crypto, 'randomBytes') + @CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null) + @call = (callback) => + @CollaboratorsInviteHandler.inviteToProject @projectId, @sendingUser, @email, @privileges, callback + + afterEach -> + @randomBytesSpy.restore() + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err, invite) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should produce the invite object', (done) -> + @call (err, invite) => + expect(invite).to.not.equal null + expect(invite).to.not.equal undefined + expect(invite).to.be.instanceof Object + expect(invite).to.have.all.keys ['_id', 'email', 'token', 'sendingUserId', 'projectId', 'privileges'] + done() + + it 'should have generated a random token', (done) -> + @call (err, invite) => + @randomBytesSpy.callCount.should.equal 1 + done() + + it 'should have called ProjectInvite.save', (done) -> + @call (err, invite) => + @ProjectInvite::save.callCount.should.equal 1 + done() + + it 'should have called _sendMessages', (done) -> + @call (err, invite) => + @CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1 + @CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser).should.equal true + done() + + describe 'when saving model produces an error', -> + + beforeEach -> + @ProjectInvite::save = sinon.spy (cb) -> cb(new Error('woops'), this) + + it 'should produce an error', (done) -> + @call (err, invite) => + expect(err).to.be.instanceof Error + done() + + describe '_sendMessages', -> + + beforeEach -> + @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, null) + @CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, null) + @call = (callback) => + @CollaboratorsInviteHandler._sendMessages @projectId, @sendingUser, @fakeInvite, callback + + describe 'when all goes well', -> + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call CollaboratorsEmailHandler.notifyUserOfProjectInvite', (done) -> + @call (err) => + @CollaboratorsEmailHandler.notifyUserOfProjectInvite.callCount.should.equal 1 + @CollaboratorsEmailHandler.notifyUserOfProjectInvite.calledWith(@projectId, @fakeInvite.email, @fakeInvite).should.equal true + done() + + it 'should call _trySendInviteNotification', (done) -> + @call (err) => + @CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 1 + @CollaboratorsInviteHandler._trySendInviteNotification.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true + done() + + describe 'when CollaboratorsEmailHandler.notifyUserOfProjectInvite produces an error', -> + + beforeEach -> + @CollaboratorsEmailHandler.notifyUserOfProjectInvite = sinon.stub().callsArgWith(3, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, invite) => + expect(err).to.be.instanceof Error + done() + + it 'should not call _trySendInviteNotification', (done) -> + @call (err) => + @CollaboratorsInviteHandler._trySendInviteNotification.callCount.should.equal 0 + done() + + describe 'when _trySendInviteNotification produces an error', -> + + beforeEach -> + @CollaboratorsInviteHandler._trySendInviteNotification = sinon.stub().callsArgWith(3, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, invite) => + expect(err).to.be.instanceof Error + done() + + describe 'revokeInvite', -> + + beforeEach -> + @ProjectInvite.remove.callsArgWith(1, null) + @CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null) + @call = (callback) => + @CollaboratorsInviteHandler.revokeInvite @projectId, @inviteId, callback + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 1 + @ProjectInvite.remove.calledWith({projectId: @projectId, _id: @inviteId}).should.equal true + done() + + it 'should call _tryCancelInviteNotification', (done) -> + @call (err) => + @CollaboratorsInviteHandler._tryCancelInviteNotification.callCount.should.equal 1 + @CollaboratorsInviteHandler._tryCancelInviteNotification.calledWith(@inviteId).should.equal true + done() + + describe 'when remove produces an error', -> + + beforeEach -> + @ProjectInvite.remove.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + describe 'resendInvite', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, null, @fakeInvite) + @CollaboratorsInviteHandler._sendMessages = sinon.stub().callsArgWith(3, null) + @call = (callback) => + @CollaboratorsInviteHandler.resendInvite @projectId, @sendingUser, @inviteId, callback + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call ProjectInvite.findOne', (done) -> + @call (err, invite) => + @ProjectInvite.findOne.callCount.should.equal 1 + @ProjectInvite.findOne.calledWith({_id: @inviteId, projectId: @projectId}).should.equal true + done() + + it 'should have called _sendMessages', (done) -> + @call (err, invite) => + @CollaboratorsInviteHandler._sendMessages.callCount.should.equal 1 + @CollaboratorsInviteHandler._sendMessages.calledWith(@projectId, @sendingUser, @fakeInvite).should.equal true + done() + + describe 'when findOne produces an error', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, invite) => + expect(err).to.be.instanceof Error + done() + + it 'should not have called _sendMessages', (done) -> + @call (err, invite) => + @CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0 + done() + + describe 'when findOne does not find an invite', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, null, null) + + it 'should not produce an error', (done) -> + @call (err, invite) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should not have called _sendMessages', (done) -> + @call (err, invite) => + @CollaboratorsInviteHandler._sendMessages.callCount.should.equal 0 + done() + + describe 'getInviteByToken', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, null, @fakeInvite) + @call = (callback) => + @CollaboratorsInviteHandler.getInviteByToken @projectId, @token, callback + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err, invite) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should produce the invite object', (done) -> + @call (err, invite) => + expect(invite).to.deep.equal @fakeInvite + done() + + it 'should call ProjectInvite.findOne', (done) -> + @call (err, invite) => + @ProjectInvite.findOne.callCount.should.equal 1 + @ProjectInvite.findOne.calledWith({projectId: @projectId, token: @token}).should.equal true + done() + + describe 'when findOne produces an error', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err, invite) => + expect(err).to.be.instanceof Error + done() + + describe 'when findOne does not find an invite', -> + + beforeEach -> + @ProjectInvite.findOne.callsArgWith(1, null, null) + + it 'should not produce an error', (done) -> + @call (err, invite) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should not produce an invite object', (done) -> + @call (err, invite) => + expect(invite).to.not.be.instanceof Error + expect(invite).to.be.oneOf [null, undefined] + done() + + describe 'acceptInvite', -> + + beforeEach -> + @fakeProject = + _id: @projectId + collaberator_refs: [] + readOnly_refs: [] + @CollaboratorsHandler.addUserIdToProject.callsArgWith(4, null) + @_getInviteByToken = sinon.stub(@CollaboratorsInviteHandler, 'getInviteByToken') + @_getInviteByToken.callsArgWith(2, null, @fakeInvite) + @CollaboratorsInviteHandler._tryCancelInviteNotification = sinon.stub().callsArgWith(1, null) + @ProjectInvite.remove.callsArgWith(1, null) + @call = (callback) => + @CollaboratorsInviteHandler.acceptInvite @projectId, @inviteId, @token, @user, callback + + afterEach -> + @_getInviteByToken.restore() + + describe 'when all goes well', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should have called getInviteByToken', (done) -> + @call (err) => + @_getInviteByToken.callCount.should.equal 1 + @_getInviteByToken.calledWith(@projectId, @token).should.equal true + done() + + it 'should have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1 + @CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true + done() + + it 'should have called ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 1 + @ProjectInvite.remove.calledWith({_id: @inviteId}).should.equal true + done() + + describe 'when the invite is for readOnly access', -> + + beforeEach -> + @fakeInvite.privileges = 'readOnly' + @_getInviteByToken.callsArgWith(2, null, @fakeInvite) + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.not.be.instanceof Error + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1 + @CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true + done() + + describe 'when getInviteByToken does not find an invite', -> + + beforeEach -> + @_getInviteByToken.callsArgWith(2, null, null) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + expect(err.name).to.equal "NotFoundError" + done() + + it 'should have called getInviteByToken', (done) -> + @call (err) => + @_getInviteByToken.callCount.should.equal 1 + @_getInviteByToken.calledWith(@projectId, @token).should.equal true + done() + + it 'should not have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 0 + done() + + it 'should not have called ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 0 + done() + + describe 'when getInviteByToken produces an error', -> + + beforeEach -> + @_getInviteByToken.callsArgWith(2, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + it 'should have called getInviteByToken', (done) -> + @call (err) => + @_getInviteByToken.callCount.should.equal 1 + @_getInviteByToken.calledWith(@projectId, @token).should.equal true + done() + + it 'should not have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 0 + done() + + it 'should not have called ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 0 + done() + + describe 'when addUserIdToProject produces an error', -> + + beforeEach -> + @CollaboratorsHandler.addUserIdToProject.callsArgWith(4, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + it 'should have called getInviteByToken', (done) -> + @call (err) => + @_getInviteByToken.callCount.should.equal 1 + @_getInviteByToken.calledWith(@projectId, @token).should.equal true + done() + + it 'should have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1 + @CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true + done() + + it 'should not have called ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 0 + done() + + describe 'when ProjectInvite.remove produces an error', -> + + beforeEach -> + @ProjectInvite.remove.callsArgWith(1, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + it 'should have called getInviteByToken', (done) -> + @call (err) => + @_getInviteByToken.callCount.should.equal 1 + @_getInviteByToken.calledWith(@projectId, @token).should.equal true + done() + + it 'should have called CollaboratorsHandler.addUserIdToProject', (done) -> + @call (err) => + @CollaboratorsHandler.addUserIdToProject.callCount.should.equal 1 + @CollaboratorsHandler.addUserIdToProject.calledWith(@projectId, @sendingUserId, @userId, @fakeInvite.privileges).should.equal true + done() + + it 'should have called ProjectInvite.remove', (done) -> + @call (err) => + @ProjectInvite.remove.callCount.should.equal 1 + done() + + describe '_tryCancelInviteNotification', -> + beforeEach -> + @inviteId = ObjectId() + @currentUser = {_id: ObjectId()} + @notification = {read: sinon.stub().callsArgWith(0, null)} + @NotificationsBuilder.projectInvite = sinon.stub().returns(@notification) + @call = (callback) => + @CollaboratorsInviteHandler._tryCancelInviteNotification @inviteId, callback + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call notification.read', (done) -> + @call (err) => + @notification.read.callCount.should.equal 1 + done() + + describe 'when notification.read produces an error', -> + beforeEach -> + @notification = {read: sinon.stub().callsArgWith(0, new Error('woops'))} + @NotificationsBuilder.projectInvite = sinon.stub().returns(@notification) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + describe "_trySendInviteNotification", -> + + beforeEach -> + @invite = + _id: ObjectId(), + token: "some_token", + sendingUserId: ObjectId(), + projectId: @project_id, + targetEmail: 'user@example.com' + createdAt: new Date(), + @sendingUser = + _id: ObjectId() + first_name: "jim" + @existingUser = {_id: ObjectId()} + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @existingUser) + @fakeProject = + _id: @project_id + name: "some project" + @ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, @fakeProject) + @notification = {create: sinon.stub().callsArgWith(0, null)} + @NotificationsBuilder.projectInvite = sinon.stub().returns(@notification) + @call = (callback) => + @CollaboratorsInviteHandler._trySendInviteNotification @project_id, @sendingUser, @invite, callback + + describe 'when the user exists', -> + + beforeEach -> + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call getUser', (done) -> + @call (err) => + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + done() + + it 'should call getProject', (done) -> + @call (err) => + @ProjectGetter.getProject.callCount.should.equal 1 + @ProjectGetter.getProject.calledWith(@project_id).should.equal true + done() + + it 'should call NotificationsBuilder.projectInvite.create', (done) -> + @call (err) => + @NotificationsBuilder.projectInvite.callCount.should.equal 1 + @notification.create.callCount.should.equal 1 + done() + + describe 'when getProject produces an error', -> + + beforeEach -> + @ProjectGetter.getProject.callsArgWith(2, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + it 'should not call NotificationsBuilder.projectInvite.create', (done) -> + @call (err) => + @NotificationsBuilder.projectInvite.callCount.should.equal 0 + @notification.create.callCount.should.equal 0 + done() + + describe 'when projectInvite.create produces an error', -> + + beforeEach -> + @notification.create.callsArgWith(0, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + describe 'when the user does not exist', -> + + beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(2, null, null) + + it 'should not produce an error', (done) -> + @call (err) => + expect(err).to.be.oneOf [null, undefined] + done() + + it 'should call getUser', (done) -> + @call (err) => + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + done() + + it 'should not call getProject', (done) -> + @call (err) => + @ProjectGetter.getProject.callCount.should.equal 0 + done() + + it 'should not call NotificationsBuilder.projectInvite.create', (done) -> + @call (err) => + @NotificationsBuilder.projectInvite.callCount.should.equal 0 + @notification.create.callCount.should.equal 0 + done() + + describe 'when the getUser produces an error', -> + + beforeEach -> + @UserGetter.getUser = sinon.stub().callsArgWith(2, new Error('woops')) + + it 'should produce an error', (done) -> + @call (err) => + expect(err).to.be.instanceof Error + done() + + it 'should call getUser', (done) -> + @call (err) => + @UserGetter.getUser.callCount.should.equal 1 + @UserGetter.getUser.calledWith({email: @invite.email}).should.equal true + done() + + it 'should not call getProject', (done) -> + @call (err) => + @ProjectGetter.getProject.callCount.should.equal 0 + done() + + it 'should not call NotificationsBuilder.projectInvite.create', (done) -> + @call (err) => + @NotificationsBuilder.projectInvite.callCount.should.equal 0 + @notification.create.callCount.should.equal 0 + done() diff --git a/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee b/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee index a98c34af28..3134cea34b 100644 --- a/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee +++ b/services/web/test/UnitTests/coffee/Editor/EditorHttpControllerTests.coffee @@ -17,7 +17,8 @@ describe "EditorHttpController", -> "./EditorController": @EditorController = {} '../../infrastructure/Metrics': @Metrics = {inc: sinon.stub()} "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} - + "../Collaborators/CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} + @project_id = "mock-project-id" @doc_id = "mock-doc-id" @user_id = "mock-user-id" @@ -102,9 +103,14 @@ describe "EditorHttpController", -> _id: @project_id owner:{_id:"something"} view: true + @invites = [ + {_id: "invite_one", email: "user-one@example.com", privileges: "readOnly", projectId: @project._id} + {_id: "invite_two", email: "user-two@example.com", privileges: "readOnly", projectId: @project._id} + ] @ProjectEditorHandler.buildProjectModelView = sinon.stub().returns(@projectModelView) @ProjectGetter.getProjectWithoutDocLines = sinon.stub().callsArgWith(1, null, @project) @CollaboratorsHandler.getMembersWithPrivilegeLevels = sinon.stub().callsArgWith(1, null, @members) + @CollaboratorsInviteHandler.getAllInvites = sinon.stub().callsArgWith(1, null, @invites) @UserGetter.getUser = sinon.stub().callsArgWith(2, null, @user) describe "when authorized", -> @@ -133,6 +139,11 @@ describe "EditorHttpController", -> .calledWith(@user_id, @project_id) .should.equal true + it 'should include the invites', -> + @CollaboratorsInviteHandler.getAllInvites + .calledWith(@project._id) + .should.equal true + it "should return the project model view, privilege level and protocol version", -> @callback.calledWith(null, @projectModelView, "owner").should.equal true diff --git a/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee b/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee index cfca6dfebe..0aafe6e5fc 100644 --- a/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Notifications/NotificationsHandlerTests.coffee @@ -60,12 +60,61 @@ describe 'NotificationsHandler', -> @key = "some key here" @messageOpts = {value:12344} @templateKey = "renderThisHtml" + @expiry = null + @forceCreate = false it "should post the message over", (done)-> - @handler.createNotification user_id, @key, @templateKey, @messageOpts, => + @handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, => args = @request.args[0][0] args.uri.should.equal "#{notificationUrl}/user/#{user_id}" args.timeout.should.equal 1000 expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts} assert.deepEqual(args.json, expectedJson) - done() \ No newline at end of file + done() + + describe 'when expiry date is supplied', -> + beforeEach -> + @key = "some key here" + @messageOpts = {value:12344} + @templateKey = "renderThisHtml" + @expiry = new Date() + @forceCreate = false + + it 'should post the message over with expiry field', (done) -> + @handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, => + args = @request.args[0][0] + args.uri.should.equal "#{notificationUrl}/user/#{user_id}" + args.timeout.should.equal 1000 + expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts, expires: @expiry} + assert.deepEqual(args.json, expectedJson) + done() + + describe 'when forceCreate is true', -> + beforeEach -> + @key = "some key here" + @messageOpts = {value:12344} + @templateKey = "renderThisHtml" + @expiry = null + @forceCreate = true + + it 'should add a forceCreate=true flag to the payload', (done) -> + @handler.createNotification user_id, @key, @templateKey, @messageOpts, @expiry, @forceCreate, => + args = @request.args[0][0] + args.uri.should.equal "#{notificationUrl}/user/#{user_id}" + args.timeout.should.equal 1000 + expectedJson = {key:@key, templateKey:@templateKey, messageOpts:@messageOpts, forceCreate: @forceCreate} + assert.deepEqual(args.json, expectedJson) + done() + + describe "markAsReadByKeyOnly", -> + beforeEach -> + @key = "some key here" + + it 'should send a delete request when a delete has been received to mark a notification', (done)-> + @handler.markAsReadByKeyOnly @key, => + opts = + uri: "#{notificationUrl}/key/#{@key}" + timeout:1000 + method: "DELETE" + @request.calledWith(opts).should.equal true + done() diff --git a/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee b/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee index eede50bfd3..dc68e5dda6 100644 --- a/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee +++ b/services/web/test/UnitTests/coffee/Project/ProjectEditorHandlerTests.coffee @@ -1,4 +1,5 @@ chai = require('chai') +expect = chai.expect should = chai.should() modulePath = "../../../../app/js/Features/Project/ProjectEditorHandler" @@ -67,12 +68,16 @@ describe "ProjectEditorHandler", -> }, privilegeLevel: "readAndWrite" }] + @invites = [ + {_id: "invite_one", email: "user-one@example.com", privileges: "readOnly", projectId: @project._id} + {_id: "invite_two", email: "user-two@example.com", privileges: "readOnly", projectId: @project._id} + ] @handler = SandboxedModule.require modulePath describe "buildProjectModelView", -> describe "with owner and members included", -> beforeEach -> - @result = @handler.buildProjectModelView @project, @members + @result = @handler.buildProjectModelView @project, @members, @invites it "should include the id", -> should.exist @result._id @@ -101,7 +106,7 @@ describe "ProjectEditorHandler", -> it "should include the deletedDocs", -> should.exist @result.deletedDocs @result.deletedDocs.should.equal @project.deletedDocs - + it "should gather readOnly_refs and collaberators_refs into a list of members", -> findMember = (id) => for member in @result.members @@ -144,6 +149,10 @@ describe "ProjectEditorHandler", -> @result.rootFolder[0].folders[0].docs[0].name.should.equal "main.tex" should.not.exist @result.rootFolder[0].folders[0].docs[0].lines + it 'should include invites', -> + should.exist @result.invites + @result.invites.should.deep.equal @invites + describe "deletedByExternalDataSource", -> it "should set the deletedByExternalDataSource flag to false when it is not there", -> @@ -168,10 +177,46 @@ describe "ProjectEditorHandler", -> compileGroup:"priority" compileTimeout: 96 @result = @handler.buildProjectModelView @project, @members - + it "should copy the owner features to the project", -> @result.features.versioning.should.equal @owner.features.versioning @result.features.collaborators.should.equal @owner.features.collaborators @result.features.compileGroup.should.equal @owner.features.compileGroup @result.features.compileTimeout.should.equal @owner.features.compileTimeout + describe 'buildOwnerAndMembersViews', -> + beforeEach -> + @owner.features = + versioning: true + collaborators: 3 + compileGroup:"priority" + compileTimeout: 22 + @result = @handler.buildOwnerAndMembersViews @members + + it 'should produce an object with owner, ownerFeatures and members keys', -> + expect(@result).to.have.all.keys ['owner', 'ownerFeatures', 'members'] + + it 'should separate the owner from the members', -> + @result.members.length.should.equal(@members.length-1) + expect(@result.owner._id).to.equal @owner._id + expect(@result.owner.email).to.equal @owner.email + expect(@result.members.filter((m) => m._id == @owner._id).length).to.equal 0 + + it 'should extract the ownerFeatures from the owner object', -> + expect(@result.ownerFeatures).to.deep.equal @owner.features + + describe 'when there is no owner', -> + beforeEach -> + # remove the owner from members list + @membersWithoutOwner = @members.filter((m) => m.user._id != @owner._id) + @result = @handler.buildOwnerAndMembersViews @membersWithoutOwner + + it 'should produce an object with owner, ownerFeatures and members keys', -> + expect(@result).to.have.all.keys ['owner', 'ownerFeatures', 'members'] + + it 'should not separate out an owner', -> + @result.members.length.should.equal @membersWithoutOwner.length + expect(@result.owner).to.equal null + + it 'should not extract the ownerFeatures from the owner object', -> + expect(@result.ownerFeatures).to.equal null diff --git a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee index 94a1edb5f2..93f00afad5 100644 --- a/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/LimitationsManagerTests.coffee @@ -30,6 +30,7 @@ describe "LimitationsManager", -> './SubscriptionLocator':@SubscriptionLocator 'settings-sharelatex' : @Settings = {} "../Collaborators/CollaboratorsHandler": @CollaboratorsHandler = {} + "../Collaborators/CollaboratorsInviteHandler": @CollaboratorsInviteHandler = {} 'logger-sharelatex':log:-> describe "allowedNumberOfCollaboratorsInProject", -> @@ -56,6 +57,7 @@ describe "LimitationsManager", -> describe "canAddXCollaborators", -> beforeEach -> @CollaboratorsHandler.getCollaboratorCount = (project_id, callback) => callback(null, @current_number) + @CollaboratorsInviteHandler.getInviteCount = (project_id, callback) => callback(null, @invite_count) sinon.stub @LimitationsManager, "allowedNumberOfCollaboratorsInProject", (project_id, callback) => callback(null, @allowed_number) @@ -65,6 +67,17 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 1 @allowed_number = 2 + @invite_count = 0 + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) + + it "should return true", -> + @callback.calledWith(null, true).should.equal true + + describe "when the project has fewer collaborators and invites than allowed", -> + beforeEach -> + @current_number = 1 + @allowed_number = 4 + @invite_count = 1 @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) it "should return true", -> @@ -74,6 +87,7 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 1 @allowed_number = 2 + @invite_count = 0 @LimitationsManager.canAddXCollaborators(@project_id, 2, @callback) it "should return false", -> @@ -83,6 +97,7 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 3 @allowed_number = 2 + @invite_count = 0 @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) it "should return false", -> @@ -92,11 +107,31 @@ describe "LimitationsManager", -> beforeEach -> @current_number = 100 @allowed_number = -1 + @invite_count = 0 @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) it "should return true", -> @callback.calledWith(null, true).should.equal true + describe 'when the project has more invites than allowed', -> + beforeEach -> + @current_number = 0 + @allowed_number = 2 + @invite_count = 2 + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) + + it "should return false", -> + @callback.calledWith(null, false).should.equal true + + describe 'when the project has more invites and collaborators than allowed', -> + beforeEach -> + @current_number = 1 + @allowed_number = 2 + @invite_count = 1 + @LimitationsManager.canAddXCollaborators(@project_id, 1, @callback) + + it "should return false", -> + @callback.calledWith(null, false).should.equal true describe "userHasSubscription", -> beforeEach -> @@ -193,7 +228,7 @@ describe "LimitationsManager", -> @LimitationsManager.userHasSubscriptionOrIsGroupMember @user, (err, hasSubOrIsGroupMember)-> hasSubOrIsGroupMember.should.equal false done() - + describe "hasGroupMembersLimitReached", -> beforeEach -> @@ -214,10 +249,10 @@ describe "LimitationsManager", -> @LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)-> limitReached.should.equal false done() - + it "should return true if the limit has been exceded", (done)-> @subscription.membersLimit = 0 @SubscriptionLocator.getUsersSubscription.callsArgWith(1, null, @subscription) @LimitationsManager.hasGroupMembersLimitReached @user_id, (err, limitReached)-> limitReached.should.equal true - done() \ No newline at end of file + done() diff --git a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee index 5a5e9fc40c..eda02ccf72 100644 --- a/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee +++ b/services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee @@ -728,7 +728,7 @@ describe "RecurlyWrapper", -> describe 'when the account does not exist', -> beforeEach -> - @apiRequest.callsArgWith(1, new Error('not found'), {statusCode: 404}, '') + @apiRequest.callsArgWith(1, null, {statusCode: 404}, '') it 'should not produce an error', (done) -> @call (err, result) => diff --git a/services/web/test/acceptance/coffee/ProjectInviteTests.coffee b/services/web/test/acceptance/coffee/ProjectInviteTests.coffee new file mode 100644 index 0000000000..8e207948a1 --- /dev/null +++ b/services/web/test/acceptance/coffee/ProjectInviteTests.coffee @@ -0,0 +1,529 @@ +expect = require("chai").expect +Async = require("async") +User = require "./helpers/User" +request = require "./helpers/request" +settings = require "settings-sharelatex" +CollaboratorsEmailHandler = require "../../../app/js/Features/Collaborators/CollaboratorsEmailHandler" + + +createInvite = (sendingUser, projectId, email, callback=(err, invite)->) -> + sendingUser.getCsrfToken (err) -> + return callback(err) if err + sendingUser.request.post { + uri: "/project/#{projectId}/invite", + json: + email: email + privileges: 'readAndWrite' + }, (err, response, body) -> + return callback(err) if err + callback(null, body.invite) + +createProject = (owner, projectName, callback=(err, projectId, project)->) -> + owner.createProject projectName, (err, projectId) -> + throw err if err + fakeProject = { + _id: projectId, + name: projectName, + owner_ref: owner + } + callback(err, projectId, fakeProject) + +createProjectAndInvite = (owner, projectName, email, callback=(err, project, invite)->) -> + createProject owner, projectName, (err, projectId, project) -> + return callback(err) if err + createInvite owner, projectId, email, (err, invite) -> + return callback(err) if err + link = CollaboratorsEmailHandler._buildInviteUrl(project, invite) + callback(null, project, invite, link) + +revokeInvite = (sendingUser, projectId, inviteId, callback=(err)->) -> + sendingUser.getCsrfToken (err) -> + return callback(err) if err + sendingUser.request.delete { + uri: "/project/#{projectId}/invite/#{inviteId}", + }, (err, response, body) -> + return callback(err) if err + callback(null) + + +# Actions +tryFollowInviteLink = (user, link, callback=(err, response, body)->) -> + user.request.get { + uri: link + baseUrl: null + }, callback + +tryAcceptInvite = (user, invite, callback=(err, response, body)->) -> + user.request.post { + uri: "/project/#{invite.projectId}/invite/#{invite._id}/accept" + json: + token: invite.token + }, callback + +tryRegisterUser = (user, email, redir, callback=(err, response, body)->) -> + user.getCsrfToken (error) => + return callback(error) if error? + user.request.post { + url: "/register" + json: + email: email + password: "some_weird_password" + redir: redir + }, callback + +tryFollowLoginLink = (user, loginLink, callback=(err, response, body)->) -> + user.getCsrfToken (error) => + return callback(error) if error? + user.request.get loginLink, callback + +tryLoginUser = (user, redir, callback=(err, response, body)->) -> + user.getCsrfToken (error) => + return callback(error) if error? + user.request.post { + url: "/login" + json: + email: user.email + password: user.password + redir: redir + }, callback + +tryGetInviteList = (user, projectId, callback=(err, response, body)->) -> + user.getCsrfToken (error) => + return callback(error) if error? + user.request.get { + url: "/project/#{projectId}/invites" + json: true + }, callback + +tryJoinProject = (user, projectId, callback=(err, response, body)->) -> + user.getCsrfToken (error) => + return callback(error) if error? + user.request.post { + url: "/project/#{projectId}/join" + qs: {user_id: user._id} + auth: + user: settings.apis.web.user + pass: settings.apis.web.pass + sendImmediately: true + json: true + jar: false + }, callback + +# Expectations +expectProjectAccess = (user, projectId, callback=(err,result)->) -> + # should have access to project + user.openProject projectId, (err) => + expect(err).to.be.oneOf [null, undefined] + callback() + +expectNoProjectAccess = (user, projectId, callback=(err,result)->) -> + # should not have access to project page + user.openProject projectId, (err) => + expect(err).to.be.instanceof Error + callback() + +expectInvitePage = (user, link, callback=(err,result)->) -> + # view invite + tryFollowInviteLink user, link, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(body).to.match new RegExp("Project Invite - .*") + callback() + +expectInvalidInvitePage = (user, link, callback=(err,result)->) -> + # view invalid invite + tryFollowInviteLink user, link, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(body).to.match new RegExp("Invalid Invite - .*") + callback() + +expectInviteRedirectToRegister = (user, link, callback=(err,result)->) -> + # view invite, redirect to `/register` + tryFollowInviteLink user, link, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.match new RegExp("^/register\?.*redir=.*$") + # follow redirect to register page and extract the redirectUrl from form + user.request.get response.headers.location, (err, response, body) -> + redirectUrl = body.match(/input name="redir" type="hidden" value="([^"]*)"/m)?[1] + loginUrl = body.match(/href="([^"]*)">\s*Login here/m)?[1] + expect(redirectUrl).to.not.be.oneOf [null, undefined] + expect(loginUrl).to.not.be.oneOf [null, undefined] + callback(null, redirectUrl, loginUrl) + +expectLoginPage = (user, loginLink, callback=(err, result)->) -> + tryFollowLoginLink user, loginLink, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(body).to.match new RegExp("Login - .*") + redirectUrl = body.match(/input name="redir" type="hidden" value="([^"]*)"/m)?[1] + callback(null, redirectUrl) + +expectLoginRedirectToInvite = (user, redir, link, callback=(err, result)->) -> + tryLoginUser user, redir, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(link).to.match new RegExp("^.*#{body.redir}\?.*$") + callback(null, null) + +expectRegistrationRedirectToInvite = (user, email, redir, link, callback=(err, result)->) -> + tryRegisterUser user, email, redir, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(link).to.match new RegExp("^.*#{body.redir}\?.*$") + callback(null, null) + +expectInviteRedirectToProject = (user, link, invite, callback=(err,result)->) -> + # view invite, redirect straight to project + tryFollowInviteLink user, link, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.equal "/project/#{invite.projectId}" + callback() + +expectAcceptInviteAndRedirect = (user, invite, callback=(err,result)->) -> + # should accept the invite and redirect to project + tryAcceptInvite user, invite, (err, response, body) => + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 302 + expect(response.headers.location).to.equal "/project/#{invite.projectId}" + callback() + +expectInviteListCount = (user, projectId, count, callback=(err)->) -> + tryGetInviteList user, projectId, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(body).to.have.all.keys ['invites'] + expect(body.invites.length).to.equal count + callback() + +expectInvitesInJoinProjectCount = (user, projectId, count, callback=(err,result)->) -> + tryJoinProject user, projectId, (err, response, body) -> + expect(err).to.be.oneOf [null, undefined] + expect(response.statusCode).to.equal 200 + expect(body.project).to.contain.keys ['invites'] + expect(body.project.invites.length).to.equal count + callback() + + + +describe "ProjectInviteTests", -> + before (done) -> + @sendingUser = new User() + @user = new User() + @site_admin = new User({email: "admin@example.com"}) + @email = 'smoketestuser@example.com' + @projectName = 'sharing test' + Async.series [ + (cb) => @user.login cb + (cb) => @user.logout cb + (cb) => @sendingUser.login cb + ], done + + describe 'creating invites', -> + + beforeEach (done) -> + @projectName = "wat" + @projectId = null + @fakeProject = null + done() + + afterEach -> + + describe 'creating two invites', -> + + beforeEach (done) -> + Async.series [ + (cb) => + createProject @sendingUser, @projectName, (err, projectId, project) => + @projectId = projectId + @fakeProject = project + cb() + ], done + + afterEach (done) -> + Async.series [ + (cb) => @sendingUser.deleteProject(@projectId, cb) + (cb) => @sendingUser.deleteProject(@projectId, cb) + ], done + + it 'should allow the project owner to create and remove invites', (done) -> + @invite = null + Async.series [ + (cb) => expectProjectAccess @sendingUser, @projectId, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 0, cb + # create invite, check invite list count + (cb) => createInvite @sendingUser, @projectId, @email, (err, invite) => + return cb(err) if err + @invite = invite + cb() + (cb) => expectInviteListCount @sendingUser, @projectId, 1, cb + (cb) => revokeInvite @sendingUser, @projectId, @invite._id, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 0, cb + # and a second time + (cb) => createInvite @sendingUser, @projectId, @email, (err, invite) => + return cb(err) if err + @invite = invite + cb() + (cb) => expectInviteListCount @sendingUser, @projectId, 1, cb + # check the joinProject view + (cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 1, cb + # revoke invite + (cb) => revokeInvite @sendingUser, @projectId, @invite._id, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 0, cb + (cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 0, cb + ], done + + it 'should allow the project owner to many invites at once', (done) -> + @inviteOne = null + @inviteTwo = null + Async.series [ + (cb) => expectProjectAccess @sendingUser, @projectId, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 0, cb + # create first invite + (cb) => createInvite @sendingUser, @projectId, @email, (err, invite) => + return cb(err) if err + @inviteOne = invite + cb() + (cb) => expectInviteListCount @sendingUser, @projectId, 1, cb + # and a second + (cb) => createInvite @sendingUser, @projectId, @email, (err, invite) => + return cb(err) if err + @inviteTwo = invite + cb() + # should have two + (cb) => expectInviteListCount @sendingUser, @projectId, 2, cb + (cb) => expectInvitesInJoinProjectCount @sendingUser, @projectId, 2, cb + # revoke first + (cb) => revokeInvite @sendingUser, @projectId, @inviteOne._id, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 1, cb + # revoke second + (cb) => revokeInvite @sendingUser, @projectId, @inviteTwo._id, cb + (cb) => expectInviteListCount @sendingUser, @projectId, 0, cb + ], done + + describe 'clicking the invite link', -> + + beforeEach (done) -> + @projectId = null + @fakeProject = null + done() + + + describe "user is logged in already", -> + + beforeEach (done) -> + Async.series [ + (cb) => + createProjectAndInvite @sendingUser, @projectName, @email, (err, project, invite, link) => + @projectId = project._id + @fakeProject = project + @invite = invite + @link = link + cb() + (cb) => + @user.login (err) => + if err + throw err + cb() + ], done + + afterEach (done) -> + Async.series [ + (cb) => @sendingUser.deleteProject(@projectId, cb) + (cb) => @sendingUser.deleteProject(@projectId, cb) + (cb) => revokeInvite(@sendingUser, @projectId, @invite._id, cb) + ], done + + describe 'user is already a member of the project', -> + + beforeEach (done) -> + Async.series [ + (cb) => expectInvitePage @user, @link, cb + (cb) => expectAcceptInviteAndRedirect @user, @invite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + ], done + + describe 'when user clicks on the invite a second time', -> + + it 'should just redirect to the project page', (done) -> + Async.series [ + (cb) => expectProjectAccess @user, @invite.projectId, cb + (cb) => expectInviteRedirectToProject @user, @link, @invite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + ], done + + describe 'when the user recieves another invite to the same project', -> + + it 'should redirect to the project page', (done) -> + Async.series [ + (cb) => + createInvite @sendingUser, @projectId, @email, (err, invite) => + if err + throw err + @secondInvite = invite + @secondLink = CollaboratorsEmailHandler._buildInviteUrl(@fakeProject, invite) + cb() + (cb) => expectInviteRedirectToProject @user, @secondLink, @secondInvite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + (cb) => revokeInvite @sendingUser, @projectId, @secondInvite._id, cb + ], done + + + describe 'user is not a member of the project', -> + + it 'should not grant access if the user does not accept the invite', (done) -> + Async.series( + [ + (cb) => expectInvitePage @user, @link, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + ) + + it 'should render the invalid-invite page if the token is invalid', (done) -> + Async.series( + [ + (cb) => + link = @link.replace(@invite.token, 'not_a_real_token') + expectInvalidInvitePage @user, link, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + ) + + it 'should allow the user to accept the invite and access the project', (done) -> + Async.series( + [ + (cb) => expectInvitePage @user, @link, cb + (cb) => expectAcceptInviteAndRedirect @user, @invite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + ], done + ) + + describe 'user is not logged in initially', -> + + before (done) -> + @user.logout done + + beforeEach (done) -> + Async.series [ + (cb) => + createProjectAndInvite @sendingUser, @projectName, @email, (err, project, invite, link) => + @projectId = project._id + @fakeProject = project + @invite = invite + @link = link + cb() + ], done + + afterEach (done) -> + Async.series [ + (cb) => @sendingUser.deleteProject(@projectId, cb) + (cb) => @sendingUser.deleteProject(@projectId, cb) + (cb) => revokeInvite(@sendingUser, @projectId, @invite._id, cb) + ], done + + describe 'registration prompt workflow with valid token', -> + + it 'should redirect to the register page', (done) -> + Async.series [ + (cb) => expectInviteRedirectToRegister(@user, @link, cb) + ], done + + it 'should allow user to accept the invite if the user registers a new account', (done) -> + Async.series [ + (cb) => + expectInviteRedirectToRegister @user, @link, (err, redirectUrl) => + @_redir = redirectUrl + cb() + (cb) => expectRegistrationRedirectToInvite @user, "some_email@example.com", @_redir, @link, cb + (cb) => expectInvitePage @user, @link, cb + (cb) => expectAcceptInviteAndRedirect @user, @invite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + ], done + + describe 'registration prompt workflow with non-valid token', -> + + before (done)-> + @user.logout done + + it 'should redirect to the register page', (done) -> + Async.series [ + (cb) => expectInviteRedirectToRegister(@user, @link, cb) + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + + it 'should display invalid-invite if the user registers a new account', (done) -> + badLink = @link.replace(@invite.token, 'not_a_real_token') + Async.series [ + (cb) => + expectInviteRedirectToRegister @user, badLink, (err, redirectUrl) => + @_redir = redirectUrl + cb() + (cb) => expectRegistrationRedirectToInvite @user, "some_email@example.com", @_redir, badLink, cb + (cb) => expectInvalidInvitePage @user, badLink, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + + describe 'login workflow with valid token', -> + + before (done)-> + @user.logout done + + it 'should redirect to the register page', (done) -> + Async.series [ + (cb) => expectInviteRedirectToRegister(@user, @link, cb) + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + + it 'should allow the user to login to view the invite', (done) -> + Async.series [ + (cb) => + expectInviteRedirectToRegister @user, @link, (err, redirectUrl, loginUrl) => + @_redir = redirectUrl + @_loginLink = loginUrl + cb() + (cb) => + expectLoginPage @user, @_loginLink, (err, redirectUrl) => + expect(@_redir).to.equal redirectUrl + cb() + (cb) => expectLoginRedirectToInvite @user, @_redir, @link, cb + (cb) => expectInvitePage @user, @link, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + + it 'should allow user to accept the invite if the user registers a new account', (done) -> + Async.series [ + (cb) => expectInvitePage @user, @link, cb + (cb) => expectAcceptInviteAndRedirect @user, @invite, cb + (cb) => expectProjectAccess @user, @invite.projectId, cb + ], done + + describe 'login workflow with non-valid token', -> + + before (done)-> + @user.logout done + + it 'should redirect to the register page', (done) -> + Async.series [ + (cb) => expectInviteRedirectToRegister(@user, @link, cb) + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done + + it 'should show the invalid-invite page once the user has logged in', (done) -> + badLink = @link.replace(@invite.token, 'not_a_real_token') + Async.series [ + (cb) => + expectInviteRedirectToRegister @user, badLink, (err, redirectUrl, loginUrl) => + @_redir = redirectUrl + @_loginLink = loginUrl + cb() + (cb) => + expectLoginPage @user, @_loginLink, (err, redirectUrl) => + expect(@_redir).to.equal redirectUrl + cb() + (cb) => expectLoginRedirectToInvite @user, @_redir, badLink, cb + (cb) => expectInvalidInvitePage @user, badLink, cb + (cb) => expectNoProjectAccess @user, @invite.projectId, cb + ], done diff --git a/services/web/test/acceptance/coffee/helpers/User.coffee b/services/web/test/acceptance/coffee/helpers/User.coffee index 888473578e..eecde65322 100644 --- a/services/web/test/acceptance/coffee/helpers/User.coffee +++ b/services/web/test/acceptance/coffee/helpers/User.coffee @@ -13,7 +13,7 @@ class User @request = request.defaults({ jar: @jar }) - + login: (callback = (error) ->) -> @getCsrfToken (error) => return callback(error) if error? @@ -28,6 +28,8 @@ class User return callback(error) if error? @id = user?._id?.toString() @_id = user?._id?.toString() + @first_name = user?.first_name + @referal_id = user?.referal_id callback() logout: (callback = (error) ->) -> @@ -59,7 +61,24 @@ class User if !body?.project_id? console.error "SOMETHING WENT WRONG CREATING PROJECT", response.statusCode, response.headers["location"], body callback(null, body.project_id) - + + deleteProject: (project_id, callback=(error)) -> + @request.delete { + url: "/project/#{project_id}" + }, (error, response, body) -> + return callback(error) if error? + callback(null) + + openProject: (project_id, callback=(error)) -> + @request.get { + url: "/project/#{project_id}" + }, (error, response, body) -> + return callback(error) if error? + if response.statusCode != 200 + err = new Error("Non-success response when opening project: #{response.statusCode}") + return callback(err) + callback(null) + addUserToProject: (project_id, email, privileges, callback = (error, user) ->) -> @request.post { url: "/project/#{project_id}/users", @@ -67,7 +86,7 @@ class User }, (error, response, body) -> return callback(error) if error? callback(null, body.user) - + makePublic: (project_id, level, callback = (error) ->) -> @request.post { url: "/project/#{project_id}/settings/admin",