diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
index 49bd994b2c..f381735969 100644
--- a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
+++ b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee
@@ -3,6 +3,7 @@ User = require("../../models/User").User
{db, ObjectId} = require("../../infrastructure/mongojs")
crypto = require 'crypto'
bcrypt = require 'bcrypt'
+EmailHelper = require("../Helpers/EmailHelper")
BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12
@@ -28,13 +29,26 @@ module.exports = AuthenticationManager =
else
callback null, null
- setUserPassword: (user_id, password, callback = (error) ->) ->
+ validateEmail: (email) ->
+ parsed = EmailHelper.parseEmail(email)
+ if !parsed?
+ return { message: 'email not valid' }
+ return null
+
+ validatePassword: (password) ->
+ if !password?
+ return { message: 'password not set' }
if (Settings.passwordStrengthOptions?.length?.max? and
- Settings.passwordStrengthOptions?.length?.max < password.length)
- return callback("password is too long")
+ password.length > Settings.passwordStrengthOptions?.length?.max)
+ return { message: "password is too long" }
if (Settings.passwordStrengthOptions?.length?.min? and
- Settings.passwordStrengthOptions?.length?.min > password.length)
- return callback("password is too short")
+ password.length < Settings.passwordStrengthOptions?.length?.min)
+ return { message: 'password is too short' }
+ return null
+
+ setUserPassword: (user_id, password, callback = (error) ->) ->
+ validation = @validatePassword(password)
+ return callback(validation.message) if validation?
bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) ->
return callback(error) if error?
diff --git a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
index bffa485f07..192f572beb 100644
--- a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
+++ b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee
@@ -32,6 +32,11 @@ module.exports = _.template """
<%= secondaryMessage %>
<% } %>
+
+
+ If the button above does not appear, please open the link in your browser here:
+ <%= ctaURL %>
+
|
diff --git a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
index ee9907c019..2b522d6c6c 100644
--- a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
+++ b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee
@@ -32,6 +32,11 @@ module.exports = _.template """
<%= secondaryMessage %>
<% } %>
+
+
+ If the button above does not appear, please open the link in your browser here:
+ <%= ctaURL %>
+
|
diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
index f88fff3057..2f37901dbe 100644
--- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee
+++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee
@@ -51,8 +51,6 @@ templates.accountMergeToOverleafAddress = CTAEmailTemplate({
"""
ctaText: () -> "Confirm Account Merge"
ctaURL: (opts) -> opts.tokenLinkUrl
- secondaryMessage: (opts) ->
- "If the button does not appear, open this link in your browser: #{opts.tokenLinkUrl}"
})
templates.accountMergeToSharelatexAddress = templates.accountMergeToOverleafAddress
@@ -97,8 +95,6 @@ templates.confirmEmail = CTAEmailTemplate({
title: () -> "Confirm Email"
message: () -> "Please confirm your email on #{settings.appName}."
ctaText: () -> "Confirm Email"
- secondaryMessage: (opts) ->
- "If the button does not appear, open this link in your browser: #{opts.confirmEmailUrl}"
ctaURL: (opts) -> opts.confirmEmailUrl
})
diff --git a/services/web/app/coffee/Features/Errors/ErrorController.coffee b/services/web/app/coffee/Features/Errors/ErrorController.coffee
index c4f3089fe0..25760bb1c3 100644
--- a/services/web/app/coffee/Features/Errors/ErrorController.coffee
+++ b/services/web/app/coffee/Features/Errors/ErrorController.coffee
@@ -13,6 +13,11 @@ module.exports = ErrorController =
res.render 'general/500',
title: "Server Error"
+ accountMergeError: (req, res)->
+ res.status(500)
+ res.render 'general/account-merge-error',
+ title: "Account Access Error"
+
handleError: (error, req, res, next) ->
user = AuthenticationController.getSessionUser(req)
if error?.code is 'EBADCSRFTOKEN'
@@ -25,10 +30,17 @@ module.exports = ErrorController =
else if error instanceof Errors.TooManyRequestsError
logger.warn {err: error, url: req.url}, "too many requests error"
res.sendStatus(429)
+ else if error instanceof Errors.InvalidError
+ logger.warn {err: error, url: req.url}, "invalid error"
+ res.status(400)
+ res.send(error.message)
else if error instanceof Errors.InvalidNameError
logger.warn {err: error, url: req.url}, "invalid name error"
res.status(400)
res.send(error.message)
+ else if error instanceof Errors.AccountMergeError
+ logger.error err: error, "account merge error"
+ ErrorController.accountMergeError req, res
else
logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear"
ErrorController.serverError req, res
diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee
index 94aeaa2a90..0b20992faa 100644
--- a/services/web/app/coffee/Features/Errors/Errors.coffee
+++ b/services/web/app/coffee/Features/Errors/Errors.coffee
@@ -82,6 +82,20 @@ EmailExistsError = (message) ->
return error
EmailExistsError.prototype.__proto__ = Error.prototype
+InvalidError = (message) ->
+ error = new Error(message)
+ error.name = "InvalidError"
+ error.__proto__ = InvalidError.prototype
+ return error
+InvalidError.prototype.__proto__ = Error.prototype
+
+AccountMergeError = (message) ->
+ error = new Error(message)
+ error.name = "AccountMergeError"
+ error.__proto__ = AccountMergeError.prototype
+ return error
+AccountMergeError.prototype.__proto__ = Error.prototype
+
module.exports = Errors =
NotFoundError: NotFoundError
ServiceNotConfiguredError: ServiceNotConfiguredError
@@ -95,3 +109,5 @@ module.exports = Errors =
V1ConnectionError: V1ConnectionError
UnconfirmedEmailError: UnconfirmedEmailError
EmailExistsError: EmailExistsError
+ InvalidError: InvalidError
+ AccountMergeError: AccountMergeError
diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee
index ea82cf6a04..fc339f9957 100644
--- a/services/web/app/coffee/Features/Exports/ExportsController.coffee
+++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee
@@ -46,10 +46,11 @@ module.exports =
}
res.send export_json: json
- exportZip: (req, res) ->
- {export_id} = req.params
+ exportDownload: (req, res) ->
+ {type, export_id} = req.params
+
AuthenticationController.getLoggedInUserId(req)
- ExportsHandler.fetchZip export_id, (err, export_zip_url) ->
+ ExportsHandler.fetchDownload export_id, type, (err, export_file_url) ->
return err if err?
- res.redirect export_zip_url
+ res.redirect export_file_url
diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
index 885f063c8b..18644a66e6 100644
--- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
+++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee
@@ -122,10 +122,9 @@ module.exports = ExportsHandler = self =
logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}"
callback err
- fetchZip: (export_id, callback=(err, zip_url) ->) ->
- console.log("#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url")
+ fetchDownload: (export_id, type, callback=(err, file_url) ->) ->
request.get {
- url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url"
+ url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/#{type}_url"
auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass }
}, (err, res, body) ->
if err?
diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee
new file mode 100644
index 0000000000..013bd2ac00
--- /dev/null
+++ b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee
@@ -0,0 +1,9 @@
+Institution = require('../../models/Institution').Institution
+logger = require("logger-sharelatex")
+ObjectId = require('mongoose').Types.ObjectId
+
+module.exports = InstitutionLocator =
+
+ findManagedInstitution: (managerId, callback)->
+ logger.log managerId: managerId, "finding managed Institution"
+ Institution.findOne managerIds: managerId, callback
diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
index b7be80cb47..079065a948 100644
--- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
+++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee
@@ -129,6 +129,8 @@ module.exports = ProjectEntityUpdateHandler = self =
Project.update {_id:project_id}, {$unset: {rootDoc_id: true}}, {}, callback
addDoc: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
+ if not SafePath.isCleanFilename docName
+ return callback new Errors.InvalidNameError("invalid element name")
self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path, project) ->
return callback(error) if error?
projectHistoryId = project.overleaf?.history?.id
@@ -166,6 +168,8 @@ module.exports = ProjectEntityUpdateHandler = self =
addFile: wrapWithLock
beforeLock: (next) ->
(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+ if not SafePath.isCleanFilename fileName
+ return callback new Errors.InvalidNameError("invalid element name")
ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
return callback(error) if error?
next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
@@ -241,6 +245,8 @@ module.exports = ProjectEntityUpdateHandler = self =
# the history unless you are making sure it is updated in some other way.
beforeLock: (next) ->
(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) ->
+ if not SafePath.isCleanFilename fileName
+ return callback(new Errors.InvalidNameError("invalid element name"))
ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) ->
return callback(error) if error?
next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback)
@@ -250,6 +256,8 @@ module.exports = ProjectEntityUpdateHandler = self =
callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl)
upsertDoc: wrapWithLock (project_id, folder_id, docName, docLines, source, userId, callback = (err, doc, folder_id, isNewDoc)->)->
+ if not SafePath.isCleanFilename docName
+ return callback new Errors.InvalidNameError("invalid element name")
ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) ->
return callback(error) if error?
return callback(new Error("Couldn't find folder")) if !folder?
@@ -272,6 +280,8 @@ module.exports = ProjectEntityUpdateHandler = self =
upsertFile: wrapWithLock
beforeLock: (next) ->
(project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback)->
+ if not SafePath.isCleanFilename fileName
+ return callback new Errors.InvalidNameError("invalid element name")
# create a new file
fileRef = new File(
name: fileName
@@ -301,6 +311,8 @@ module.exports = ProjectEntityUpdateHandler = self =
callback null, newFileRef, !existingFile?, existingFile
upsertDocWithPath: wrapWithLock (project_id, elementPath, docLines, source, userId, callback) ->
+ if not SafePath.isCleanPath elementPath
+ return callback new Errors.InvalidNameError("invalid element name")
docName = path.basename(elementPath)
folderPath = path.dirname(elementPath)
self.mkdirp.withoutLock project_id, folderPath, (err, newFolders, folder) ->
@@ -312,6 +324,8 @@ module.exports = ProjectEntityUpdateHandler = self =
upsertFileWithPath: wrapWithLock
beforeLock: (next) ->
(project_id, elementPath, fsPath, linkedFileData, userId, callback)->
+ if not SafePath.isCleanPath elementPath
+ return callback new Errors.InvalidNameError("invalid element name")
fileName = path.basename(elementPath)
folderPath = path.dirname(elementPath)
# create a new file
@@ -351,6 +365,9 @@ module.exports = ProjectEntityUpdateHandler = self =
self.deleteEntity.withoutLock project_id, element._id, type, userId, callback
mkdirp: wrapWithLock (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)->
+ for folder in path.split('/')
+ if folder.length > 0 and not SafePath.isCleanFilename folder
+ return callback new Errors.InvalidNameError("invalid element name")
ProjectEntityMongoUpdateHandler.mkdirp project_id, path, callback
addFolder: wrapWithLock (project_id, parentFolder_id, folderName, callback) ->
diff --git a/services/web/app/coffee/Features/Project/SafePath.coffee b/services/web/app/coffee/Features/Project/SafePath.coffee
index a77f83ce2b..4e5a288ea9 100644
--- a/services/web/app/coffee/Features/Project/SafePath.coffee
+++ b/services/web/app/coffee/Features/Project/SafePath.coffee
@@ -52,6 +52,7 @@ load = () ->
MAX_PATH = 1024 # Maximum path length, in characters. This is fairly arbitrary.
SafePath =
+ # convert any invalid characters to underscores in the given filename
clean: (filename) ->
filename = filename.replace BADCHAR_RX, '_'
# for BADFILE_RX replace any matches with an equal number of underscores
@@ -61,12 +62,27 @@ load = () ->
filename = filename.replace BLOCKEDFILE_RX, "@$1"
return filename
+ # returns whether the filename is 'clean' (does not contain any invalid
+ # characters or reserved words)
isCleanFilename: (filename) ->
return SafePath.isAllowedLength(filename) &&
!BADCHAR_RX.test(filename) &&
!BADFILE_RX.test(filename) &&
!BLOCKEDFILE_RX.test(filename)
+ # returns whether a full path is 'clean' - e.g. is a full or relative path
+ # that points to a file, and each element passes the rules in 'isCleanFilename'
+ isCleanPath: (path) ->
+ elements = path.split('/')
+
+ lastElementIsEmpty = elements[elements.length - 1].length == 0
+ return false if lastElementIsEmpty
+
+ for element in elements
+ return false if element.length > 0 && !SafePath.isCleanFilename element
+
+ return true
+
isAllowedLength: (pathname) ->
return pathname.length > 0 && pathname.length <= MAX_PATH
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
index 0fb7b6e7c9..3ebd096b70 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
@@ -52,18 +52,6 @@ module.exports =
return res.sendStatus 500
res.send()
- renderSubscriptionGroupAdminPage: (req, res, next)->
- user_id = AuthenticationController.getLoggedInUserId(req)
- getManagedSubscription user_id, (error, subscription)->
- return next(error) if error?
- if !subscription?.groupPlan
- return res.redirect("/user/subscription")
- SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)->
- res.render "subscriptions/group_admin",
- title: 'group_admin'
- users: users
- subscription: subscription
-
exportGroupCsv: (req, res)->
user_id = AuthenticationController.getLoggedInUserId(req)
logger.log user_id: user_id, "exporting group csv"
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
index badcde4c36..bb849fa7bd 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
@@ -11,6 +11,7 @@ TeamInvitesHandler = require("./TeamInvitesHandler")
EmailHandler = require("../Email/EmailHandler")
settings = require("settings-sharelatex")
NotificationsBuilder = require("../Notifications/NotificationsBuilder")
+UserMembershipViewModel = require("../UserMembership/UserMembershipViewModel")
module.exports = SubscriptionGroupHandler =
@@ -31,12 +32,12 @@ module.exports = SubscriptionGroupHandler =
logger.err err:err, "error adding user to group"
return callback(err)
NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read()
- userViewModel = buildUserViewModel(user)
+ userViewModel = UserMembershipViewModel.build(user)
callback(err, userViewModel)
else
TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) ->
return callback(err) if err?
- userViewModel = buildEmailInviteViewModel(newEmail)
+ userViewModel = UserMembershipViewModel.build(newEmail)
callback(err, userViewModel)
removeUserFromGroup: (subscriptionId, userToRemove_id, callback)->
@@ -51,28 +52,6 @@ module.exports = SubscriptionGroupHandler =
replaceInArray Subscription, "member_ids", oldId, newId, callback
- getPopulatedListOfMembers: (subscriptionId, callback)->
- SubscriptionLocator.getSubscription subscriptionId, (err, subscription)->
- users = []
-
- for email in subscription.invited_emails or []
- users.push buildEmailInviteViewModel(email)
-
- for teamInvite in subscription.teamInvites or []
- users.push buildEmailInviteViewModel(teamInvite.email)
-
- jobs = _.map subscription.member_ids, (user_id)->
- return (cb)->
- UserGetter.getUser user_id, (err, user)->
- if err? or !user?
- users.push _id:user_id
- return cb()
- userViewModel = buildUserViewModel(user)
- users.push(userViewModel)
- cb()
- async.series jobs, (err)->
- callback(err, users)
-
isUserPartOfGroup: (user_id, subscription_id, callback=(err, partOfGroup)->)->
SubscriptionLocator.getSubscriptionByMemberIdAndId user_id, subscription_id, (err, subscription)->
if subscription?
@@ -99,18 +78,3 @@ replaceInArray = (model, property, oldValue, newValue, callback) ->
model.update query, { $addToSet: setNewValue }, { multi: true }, (error) ->
return callback(error) if error?
model.update query, { $pull: setOldValue }, { multi: true }, callback
-
-buildUserViewModel = (user)->
- u =
- email: user.email
- first_name: user.first_name
- last_name: user.last_name
- invite: user.holdingAccount
- _id: user._id
- return u
-
-buildEmailInviteViewModel = (email) ->
- return {
- email: email
- invite: true
- }
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
index 8950e3c5bd..79954b33e0 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
@@ -20,7 +20,8 @@ module.exports =
webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription
- webRouter.get '/subscription/group', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSubscriptionGroupAdminPage
+ webRouter.get '/subscription/group', AuthenticationController.requireLogin(), (req, res, next) ->
+ res.redirect('/manage/group/members') # legacy route
webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup
webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv
webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup
diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
index edcc58d57e..299320f061 100644
--- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
+++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
@@ -55,7 +55,10 @@ module.exports = SubscriptionUpdater =
if err?
logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group"
return callback(err)
- FeaturesUpdater.refreshFeatures user_id, callback
+ UserGetter.getUserOrUserStubById user_id, {}, (error, user, isStub) ->
+ return callback(error) if error
+ return callback() if isStub
+ FeaturesUpdater.refreshFeatures user_id, callback
deleteWithV1Id: (v1TeamId, callback)->
Subscription.deleteOne { "overleaf.id": v1TeamId }, callback
diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee
index f6d16f5c75..7eb1e4ba20 100644
--- a/services/web/app/coffee/Features/User/UserGetter.coffee
+++ b/services/web/app/coffee/Features/User/UserGetter.coffee
@@ -71,15 +71,18 @@ module.exports = UserGetter =
db.users.find { _id: { $in: user_ids} }, projection, callback
- getUserOrUserStubById: (user_id, projection, callback = (error, user) ->) ->
+ getUserOrUserStubById: (user_id, projection, callback = (error, user, isStub) ->) ->
try
query = _id: ObjectId(user_id.toString())
catch e
return callback(new Error(e))
db.users.findOne query, projection, (error, user) ->
return callback(error) if error?
- return callback(null, user) if user?
- db.userstubs.findOne query, projection, callback
+ return callback(null, user, false) if user?
+ db.userstubs.findOne query, projection, (error, user) ->
+ return callback(error) if error
+ return callback() if !user?
+ callback(null, user, true)
# check for duplicate email address. This is also enforced at the DB level
ensureUniqueEmailAddress: (newEmail, callback) ->
diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
index 1291142dab..52d731c4bc 100644
--- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
+++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee
@@ -13,17 +13,10 @@ settings = require "settings-sharelatex"
EmailHelper = require("../Helpers/EmailHelper")
module.exports = UserRegistrationHandler =
- hasZeroLengths : (props) ->
- hasZeroLength = false
- props.forEach (prop) ->
- if prop.length == 0
- hasZeroLength = true
- return hasZeroLength
-
_registrationRequestIsValid : (body, callback)->
- email = EmailHelper.parseEmail(body.email) or ''
- password = body.password
- if @hasZeroLengths([password, email])
+ invalidEmail = AuthenticationManager.validateEmail(body.email or '')
+ invalidPassword = AuthenticationManager.validatePassword(body.password or '')
+ if invalidEmail? or invalidPassword?
return false
else
return true
diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee
new file mode 100644
index 0000000000..bc673e1d5c
--- /dev/null
+++ b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee
@@ -0,0 +1,65 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserMembershipHandler = require('./UserMembershipHandler')
+logger = require("logger-sharelatex")
+
+module.exports =
+ index: (entityName, req, res, next)->
+ userId = AuthenticationController.getLoggedInUserId(req)
+
+ UserMembershipHandler.getEntity entityName, userId, (error, entity)->
+ return next(error) if error?
+ UserMembershipHandler.getUsers entityName, entity, (error, users)->
+ return next(error) if error?
+ res.render "user_membership/index",
+ users: users
+ entity: entity
+ translations: getTranslationsFor(entityName)
+ paths: getPathsFor(entityName)
+
+ add: (entityName, req, res, next)->
+ userId = AuthenticationController.getLoggedInUserId(req)
+ email = req.body.email
+ return res.sendStatus 422 unless email
+
+ UserMembershipHandler.getEntity entityName, userId, (error, entity)->
+ return next(error) if error?
+ UserMembershipHandler.addUser entityName, entity, email, (error, user)->
+ return next(error) if error?
+ res.json(user: user)
+
+ remove: (entityName, req, res, next)->
+ loggedInUserId = AuthenticationController.getLoggedInUserId(req)
+ userId = req.params.userId
+
+ UserMembershipHandler.getEntity entityName, loggedInUserId, (error, entity)->
+ return next(error) if error?
+ UserMembershipHandler.removeUser entityName, entity, userId, (error, user)->
+ return next(error) if error?
+ res.send()
+
+getTranslationsFor = (entityName) ->
+ switch entityName
+ when 'group'
+ title: 'group_account'
+ remove: 'remove_from_group'
+ when 'groupManagers'
+ title: 'group_managers'
+ remove: 'remove_manager'
+ when 'institution'
+ title: 'institution_managers'
+ remove: 'remove_manager'
+
+
+getPathsFor = (entityName) ->
+ switch entityName
+ when 'group'
+ addMember: '/subscription/invites'
+ removeMember: '/subscription/group/user'
+ removeInvite: '/subscription/invites'
+ exportMembers: '/subscription/group/export'
+ when 'groupManagers'
+ addMember: "/manage/group/managers"
+ removeMember: "/manage/group/managers"
+ when 'institution'
+ addMember: "/manage/institution/managers"
+ removeMember: "/manage/institution/managers"
diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee
new file mode 100644
index 0000000000..fa7b75fcb9
--- /dev/null
+++ b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee
@@ -0,0 +1,85 @@
+async = require("async")
+Errors = require('../Errors/Errors')
+SubscriptionLocator = require('../Subscription/SubscriptionLocator')
+InstitutionsLocator = require('../Institutions/InstitutionsLocator')
+UserMembershipViewModel = require('./UserMembershipViewModel')
+UserGetter = require('../User/UserGetter')
+logger = require('logger-sharelatex')
+
+module.exports =
+ getEntity: (entityName, userId, callback = (error, entity) ->) ->
+ switch entityName
+ when 'group' then getGroupSubscription(userId, callback)
+ when 'groupManagers'
+ getGroupSubscription userId, (error, subscription) ->
+ subscription.membersLimit = null if subscription # managers are unlimited
+ callback(error, subscription)
+ when 'institution' then getInstitution(userId, callback)
+ else callback(new Errors.NotFoundError("No such entity: #{entityName}"))
+
+ getUsers: (entityName, entity, callback = (error, users) ->) ->
+ attributes = switch entityName
+ when 'group' then ['invited_emails', 'teamInvites', 'member_ids']
+ when 'groupManagers' then ['manager_ids']
+ when 'institution' then ['managerIds']
+ getPopulatedListOfMembers(entity, attributes, callback)
+
+ addUser: (entityName, entity, email, callback = (error, user) ->) ->
+ attribute = switch entityName
+ when 'groupManagers' then 'manager_ids'
+ when 'institution' then 'managerIds'
+ unless attribute
+ return callback(new Errors.NotFoundError("Cannot add user to entity: #{entityName}"))
+ UserGetter.getUserByAnyEmail email, (error, user) ->
+ error ||= new Errors.NotFoundError("No user found with email #{email}") unless user
+ return callback(error) if error?
+ addUserToEntity entity, attribute, user, (error) ->
+ callback(error, UserMembershipViewModel.build(user))
+
+ removeUser: (entityName, entity, userId, callback = (error) ->) ->
+ attribute = switch entityName
+ when 'groupManagers' then 'manager_ids'
+ when 'institution' then 'managerIds'
+ else callback(new Errors.NotFoundError("Cannot remove user from entity: #{entityName}"))
+ removeUserFromEntity entity, attribute, userId, callback
+
+getGroupSubscription = (managerId, callback = (error, subscription) ->) ->
+ SubscriptionLocator.findManagedSubscription managerId, (err, subscription)->
+ if subscription? and subscription.groupPlan
+ logger.log managerId: managerId, 'got managed subscription'
+ else
+ err ||= new Errors.NotFoundError("No subscription found managed by user #{managerId}")
+
+ callback(err, subscription)
+
+getInstitution = (managerId, callback = (error, institution) ->) ->
+ InstitutionsLocator.findManagedInstitution managerId, (err, institution)->
+ if institution?
+ logger.log managerId: managerId, 'got managed subscription'
+ else
+ err ||= new Errors.NotFoundError("No institution found managed by user #{managerId}")
+
+ callback(err, institution)
+
+getPopulatedListOfMembers = (entity, attributes, callback = (error, users)->)->
+ userObjects = []
+
+ for attribute in attributes
+ for userObject in entity[attribute] or []
+ # userObject can be an email as String, a user id as ObjectId or an
+ # invite as Object with an email attribute as String. We want to pass to
+ # UserMembershipViewModel either an email as (String) or a user id (ObjectId)
+ userIdOrEmail = userObject.email || userObject
+ userObjects.push userIdOrEmail
+
+ async.map userObjects, UserMembershipViewModel.buildAsync, callback
+
+addUserToEntity = (entity, attribute, user, callback = (error)->) ->
+ fieldUpdate = {}
+ fieldUpdate[attribute] = user._id
+ entity.update { $addToSet: fieldUpdate }, callback
+
+removeUserFromEntity = (entity, attribute, userId, callback = (error)->) ->
+ fieldUpdate = {}
+ fieldUpdate[attribute] = userId
+ entity.update { $pull: fieldUpdate }, callback
diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee
new file mode 100644
index 0000000000..223b412020
--- /dev/null
+++ b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee
@@ -0,0 +1,26 @@
+AuthenticationController = require('../Authentication/AuthenticationController')
+UserMembershipController = require './UserMembershipController'
+
+module.exports =
+ apply: (webRouter) ->
+ webRouter.get '/manage/group/members',
+ AuthenticationController.requireLogin(),
+ (req, res, next) -> UserMembershipController.index('group', req, res, next)
+
+
+ regularEntitites =
+ group: 'groupManagers'
+ institution: 'institution'
+ for pathName, entityName of regularEntitites
+ do (pathName, entityName) ->
+ webRouter.get "/manage/#{pathName}/managers",
+ AuthenticationController.requireLogin(),
+ (req, res, next) -> UserMembershipController.index(entityName, req, res, next)
+
+ webRouter.post "/manage/#{pathName}/managers",
+ AuthenticationController.requireLogin(),
+ (req, res, next) -> UserMembershipController.add(entityName, req, res, next)
+
+ webRouter.delete "/manage/#{pathName}/managers/:userId",
+ AuthenticationController.requireLogin(),
+ (req, res, next) -> UserMembershipController.remove(entityName, req, res, next)
diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee
new file mode 100644
index 0000000000..ac5ba42168
--- /dev/null
+++ b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee
@@ -0,0 +1,45 @@
+ObjectId = require('mongojs').ObjectId
+UserGetter = require('../User/UserGetter')
+
+module.exports = UserMembershipViewModel =
+ build: (userOrEmail) ->
+ if userOrEmail._id
+ buildUserViewModel userOrEmail
+ else
+ buildUserViewModelWithEmail userOrEmail
+
+
+ buildAsync: (userOrIdOrEmail, callback = (error, viewModel)->) ->
+ unless userOrIdOrEmail instanceof ObjectId
+ # userOrIdOrEmail is a user or an email and can be parsed by #build
+ return callback(null, UserMembershipViewModel.build(userOrIdOrEmail))
+
+ userId = userOrIdOrEmail
+ projection = { email: 1, first_name: 1, last_name: 1 }
+ UserGetter.getUserOrUserStubById userId, projection, (error, user, isStub) ->
+ if error? or !user?
+ return callback(null, buildUserViewModelWithId(userId.toString()))
+ if isStub
+ return callback(null, buildUserViewModelWithStub(user))
+ callback(null, buildUserViewModel(user))
+
+
+buildUserViewModel = (user, isInvite = false) ->
+ _id: user._id or null
+ email: user.email or null
+ first_name: user.first_name or null
+ last_name: user.last_name or null
+ invite: isInvite
+
+
+buildUserViewModelWithEmail = (email) ->
+ buildUserViewModel({ email }, true)
+
+
+buildUserViewModelWithStub = (user) ->
+ # user stubs behave as invites
+ buildUserViewModel(user, true)
+
+
+buildUserViewModelWithId = (id) ->
+ buildUserViewModel({ _id: id }, false)
diff --git a/services/web/app/coffee/models/Institution.coffee b/services/web/app/coffee/models/Institution.coffee
new file mode 100644
index 0000000000..decf114a96
--- /dev/null
+++ b/services/web/app/coffee/models/Institution.coffee
@@ -0,0 +1,11 @@
+mongoose = require 'mongoose'
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+
+InstitutionSchema = new Schema
+ v1Id: { type: Number, required: true }
+ managerIds: [ type:ObjectId, ref:'User' ]
+
+mongoose.model 'Institution', InstitutionSchema
+exports.Institution = mongoose.model 'Institution'
+exports.InstitutionSchema = InstitutionSchema
diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee
index 2efb45370a..8a29a800b0 100644
--- a/services/web/app/coffee/models/User.coffee
+++ b/services/web/app/coffee/models/User.coffee
@@ -20,7 +20,6 @@ UserSchema = new Schema
institution : {type : String, default : ''}
hashedPassword : String
isAdmin : {type : Boolean, default : false}
- confirmed : {type : Boolean, default : false}
signUpDate : {type : Date, default: () -> new Date() }
lastLoggedIn : {type : Date}
lastLoginIp : {type : String, default : ''}
diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee
index e072d6d0f9..592ddaa061 100644
--- a/services/web/app/coffee/router.coffee
+++ b/services/web/app/coffee/router.coffee
@@ -51,6 +51,7 @@ Features = require('./infrastructure/Features')
LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
TemplatesRouter = require './Features/Templates/TemplatesRouter'
InstitutionsController = require './Features/Institutions/InstitutionsController'
+UserMembershipRouter = require './Features/UserMembership/UserMembershipRouter'
logger = require("logger-sharelatex")
_ = require("underscore")
@@ -85,6 +86,7 @@ module.exports = class Router
AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter)
LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter)
TemplatesRouter.apply(webRouter)
+ UserMembershipRouter.apply(webRouter)
Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter)
@@ -248,7 +250,7 @@ module.exports = class Router
webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject
webRouter.get '/project/:project_id/export/:export_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportStatus
- webRouter.get '/project/:project_id/export/:export_id/zip', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportZip
+ webRouter.get '/project/:project_id/export/:export_id/:type', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportDownload
webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects
diff --git a/services/web/app/views/_metadata.pug b/services/web/app/views/_metadata.pug
index 4857162a9e..15f08b038b 100644
--- a/services/web/app/views/_metadata.pug
+++ b/services/web/app/views/_metadata.pug
@@ -25,10 +25,16 @@
//- Image
-if (metadata && metadata.image)
+ //- from the CMS
meta(itemprop="image", name="image", content=metadata.image.fields.file.url)
+-else if (metadata && metadata.image_src)
+ //- pages with custom metadata images, metadata.image_src is the full image URL
+ meta(itemprop="image", name="image", content=metadata.image_src)
-else if (settings.overleaf)
+ //- the default image for Overleaf
meta(itemprop="image", name="image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
-else
+ //- the default image for ShareLaTeX
meta(itemprop="image", name="image", content='/touch-icon-192x192.png')
//- Keywords
@@ -45,8 +51,15 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar
-if (metadata && metadata.twitterDescription)
meta(itemprop="twitter:description", content=metadata.twitterDescription)
-if (metadata && metadata.twitterImage)
+ //- from the CMS
meta(itemprop="image", name="twitter:image", content=metadata.twitterImage.fields.file.url)
meta(itemprop="image", name="twitter:image:alt", content=metadata.twitterImage.fields.title)
+-else if (settings.overleaf)
+ //- the default image for Overleaf
+ meta(itemprop="image", name="twitter:image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
+-else
+ //- the default image for ShareLaTeX
+ meta(itemprop="image", name="twitter:image", content='/touch-icon-192x192.png')
//- Open Graph
//- to do - add og:url
@@ -55,7 +68,14 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar
-if (metadata && metadata.openGraphDescription)
meta(itemprop="description", name="og:description", content=metadata.openGraphDescription)
-if (metadata && metadata.openGraphImage)
+ //- from the CMS
meta(itemprop="image", name="og:image", content=metadata.openGraphImage.fields.file.url)
+-else if (settings.overleaf)
+ //- the default image for Overleaf
+ meta(itemprop="image", name="og:image", content=buildImgPath('ol-brand/overleaf_og_logo.png'))
+-else
+ //- the default image for ShareLaTeX
+ meta(itemprop="image", name="og:image", content='/touch-icon-192x192.png')
-if (metadata && metadata.openGraphType)
meta(name="og:type", metadata.openGraphType)
-else
diff --git a/services/web/app/views/general/account-merge-error.pug b/services/web/app/views/general/account-merge-error.pug
new file mode 100644
index 0000000000..68af40a267
--- /dev/null
+++ b/services/web/app/views/general/account-merge-error.pug
@@ -0,0 +1,11 @@
+extends ../layout
+
+block content
+ .content.content-alt
+ .container
+ .row
+ .col-md-6.col-md-offset-3
+ .card
+ .page-header
+ h1 Account Access Error
+ p.text-danger Sorry, an error occurred accessing your account. Please #[a(href="" ng-controller="ContactModal" ng-click="contactUsModal()") contact support] and provide any email addresses that you have used to sign in to Overleaf and/or ShareLaTeX for assistance.
diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug
index 44410a44a8..f0d1a11666 100644
--- a/services/web/app/views/project/editor/pdf.pug
+++ b/services/web/app/views/project/editor/pdf.pug
@@ -106,7 +106,7 @@ div.full-size.pdf(ng-controller="PdfController")
| #{translate("code_check_failed")}
a(
href,
- ng-click="switchToFlatLayout()"
+ ng-click="switchToFlatLayout('pdf')"
ng-show="ui.pdfLayout == 'sideBySide'"
tooltip=translate('full_screen')
tooltip-placement="bottom"
@@ -116,7 +116,7 @@ div.full-size.pdf(ng-controller="PdfController")
i.full-screen
a(
href,
- ng-click="switchToSideBySideLayout()"
+ ng-click="switchToSideBySideLayout('editor')"
ng-show="ui.pdfLayout == 'flat'"
tooltip=translate('split_screen')
tooltip-placement="bottom"
diff --git a/services/web/app/views/project/editor/share.pug b/services/web/app/views/project/editor/share.pug
index 17da2df67a..ac238d7ad5 100644
--- a/services/web/app/views/project/editor/share.pug
+++ b/services/web/app/views/project/editor/share.pug
@@ -19,7 +19,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
) #{translate('turn_on_link_sharing')}
span
a(
- href="/learn/Kb/what_is_link_sharing"
+ href="/learn/how-to/What_is_Link_Sharing%3F"
target="_blank"
)
i.fa.fa-question-circle(
@@ -38,7 +38,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
) #{translate('turn_off_link_sharing')}
span
a(
- href="/learn/Kb/what_is_link_sharing"
+ href="/learn/how-to/What_is_Link_Sharing%3F"
target="_blank"
)
i.fa.fa-question-circle(
diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug
index 50d7f8d2ef..f89b26925c 100644
--- a/services/web/app/views/project/list/modals.pug
+++ b/services/web/app/views/project/list/modals.pug
@@ -98,7 +98,7 @@ script(type='text/ng-template', id='renameProjectModalTemplate')
) ×
h3 #{translate("rename_project")}
.modal-body
- .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}}
+ .alert.alert-danger(ng-show="state.error.message") {{state.error.message}}
.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
form(name="renameProjectForm", novalidate)
input.form-control(
@@ -127,7 +127,7 @@ script(type='text/ng-template', id='cloneProjectModalTemplate')
) ×
h3 #{translate("copy_project")}
.modal-body
- .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}}
+ .alert.alert-danger(ng-show="state.error.message") {{state.error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : state.error.message}}
.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
form(name="cloneProjectForm", novalidate)
.form-group
@@ -161,7 +161,7 @@ script(type='text/ng-template', id='newProjectModalTemplate')
) ×
h3 #{translate("new_project")}
.modal-body
- .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}}
+ .alert.alert-danger(ng-show="state.error.message") {{state.error.message}}
.alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")}
form(novalidate, name="newProjectForm")
input.form-control(
@@ -262,6 +262,20 @@ script(type="text/ng-template", id="uploadProjectModalTemplate")
.modal-footer
button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
+script(type="text/ng-template", id="showErrorModalTemplate")
+ .modal-header
+ button.close(
+ type="button"
+ data-dismiss="modal"
+ ng-click="cancel()"
+ ) ×
+ h3 #{translate("generic_something_went_wrong")}
+ .modal-body
+ .alert.alert-danger(ng-show="error.message") {{error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : error.message}}
+ .alert.alert-danger(ng-show="error && !error.message") #{translate("generic_something_went_wrong")}
+ .modal-footer
+ button.btn.btn-default(ng-click="cancel()") #{translate("cancel")}
+
script(type="text/ng-template", id="userProfileModalTemplate")
.modal-header
button.close(
diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug
index 9acf763bfe..b7168de1cf 100644
--- a/services/web/app/views/user/settings.pug
+++ b/services/web/app/views/user/settings.pug
@@ -33,7 +33,7 @@ block content
required,
ng-model="email",
ng-init="email = "+JSON.stringify(user.email),
- ng-model-options="{ updateOn: 'blur' }"
+ ng-model-options="{ pdateOn: 'blur' }"
)
span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
| #{translate("must_be_email_address")}
@@ -73,11 +73,24 @@ block content
label.control-label #{translate("last_name")}
div.form-control(readonly="true") #{user.last_name}
-
- if !externalAuthenticationSystemUsed()
- .col-md-5.col-md-offset-1
- h3 #{translate("change_password")}
- form(async-form="changepassword", name="changePasswordForm", action="/user/password/update", method="POST", novalidate)
+ .col-md-5.col-md-offset-1
+ h3 #{translate("change_password")}
+ if externalAuthenticationSystemUsed() && !settings.overleaf
+ p
+ Password settings are managed externally
+ else
+ - var submitAction
+ if settings.overleaf
+ - submitAction = '/user/change_password/v1'
+ else
+ - submitAction = '/user/password/update'
+ form(
+ async-form="changepassword"
+ name="changePasswordForm"
+ action=submitAction
+ method="POST"
+ novalidate
+ )
input(type="hidden", name="_csrf", value=csrfToken)
.form-group
label(for='currentPassword') #{translate("current_password")}
@@ -121,15 +134,6 @@ block content
ng-disabled="changePasswordForm.$invalid"
) #{translate("change")}
- else
- if settings.overleaf && settings.createV1AccountOnLogin
- .col-md-5.col-md-offset-1
- h3 #{translate("change_password")}
- p
- | To change your password,
- | please go to #[a(href='/sign_in_to_v1?return_to=/users/edit%23details') Overleaf v1 settings]
-
-
| !{moduleIncludes("userSettings", locals)}
//- The beta program doesn't make much sense to include while v2 is going
diff --git a/services/web/app/views/subscriptions/group_admin.pug b/services/web/app/views/user_membership/index.pug
similarity index 68%
rename from services/web/app/views/subscriptions/group_admin.pug
rename to services/web/app/views/user_membership/index.pug
index cdb18a0320..4a512a1c11 100644
--- a/services/web/app/views/subscriptions/group_admin.pug
+++ b/services/web/app/views/user_membership/index.pug
@@ -5,16 +5,16 @@ block content
.container
.row
.col-md-10.col-md-offset-1
- .card(ng-controller="SubscriptionGroupMembersController")
+ .card(ng-controller="UserMembershipController")
.page-header
.pull-right(ng-cloak)
- small(ng-show="selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})}
+ small(ng-show="groupSize && selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})}
a.btn.btn-danger(
href,
ng-show="selectedUsers.length > 0"
ng-click="removeMembers()"
- ) #{translate("remove_from_group")}
- h1 #{translate("group_account")}
+ ) #{translate(translations.remove)}
+ h1 #{translate(translations.title)}
.row-spaced-small
ul.list-unstyled.structured-list(
@@ -35,7 +35,7 @@ block content
span.header #{translate("accepted_invite")}
li.container-fluid(
ng-repeat="user in users | orderBy:'email':true",
- ng-controller="SubscriptionGroupMemberListItemController"
+ ng-controller="UserMembershipListItemController"
)
.row
.col-md-5
@@ -60,7 +60,7 @@ block content
small #{translate("no_members")}
hr
- div(ng-if="users.length < groupSize", ng-cloak)
+ div(ng-if="!groupSize || users.length < groupSize", ng-cloak)
p.small #{translate("add_more_members")}
form.form
.row
@@ -74,18 +74,16 @@ block content
)
.col-xs-4
button.btn.btn-primary(ng-click="addMembers()") #{translate("add")}
- .col-xs-2
- a(href="/subscription/group/export") Export CSV
+ .col-xs-2(ng-if="paths.exportMembers", ng-cloak)
+ a(href=paths.exportMembers) Export CSV
- div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak)
+ div(ng-if="groupSize && users.length >= groupSize && users.length > 0", ng-cloak)
.row
- .col-xs-2.col-xs-offset-10
- a(href="/subscription/group/export") Export CSV
+ .col-xs-2.col-xs-offset-10(ng-if="paths.exportMembers", ng-cloak)
+ a(href=paths.exportMembers) Export CSV
script(type="text/javascript").
window.users = !{JSON.stringify(users)};
- window.groupSize = #{subscription.membersLimit};
-
-
-
+ window.paths = !{JSON.stringify(paths)};
+ window.groupSize = #{entity.membersLimit || 'null'};
diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee
index 9097c4e433..5136ef19f2 100644
--- a/services/web/config/settings.defaults.coffee
+++ b/services/web/config/settings.defaults.coffee
@@ -91,7 +91,7 @@ module.exports = settings =
# running which conflict, or want to run the web process on port 80.
internal:
web:
- port: webPort = 3000
+ port: webPort = process.env['WEB_PORT'] or 3000
host: process.env['LISTEN_ADDRESS'] or 'localhost'
documentupdater:
port: docUpdaterPort = 3003
@@ -192,7 +192,7 @@ module.exports = settings =
#clsiCookieKey: "clsiserver"
# Same, but with http auth credentials.
- httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000'
+ httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@#{siteUrl}'
maxEntitiesPerProject: 2000
diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json
index 8fe259f82c..37a05d1681 100644
--- a/services/web/npm-shrinkwrap.json
+++ b/services/web/npm-shrinkwrap.json
@@ -6962,9 +6962,9 @@
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz"
},
"metrics-sharelatex": {
- "version": "1.7.1",
- "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
- "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#166961924c599b1f9468f2e17846fa2a9d12372d",
+ "version": "1.8.0",
+ "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0",
+ "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#e57f1a84539cdf0398d0768b7f7af0c79ea5b05b",
"dependencies": {
"coffee-script": {
"version": "1.6.0",
diff --git a/services/web/package.json b/services/web/package.json
index 0bacc0d2d1..d43544dcbe 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -62,7 +62,7 @@
"mailchimp-api-v3": "^1.12.0",
"marked": "^0.3.5",
"method-override": "^2.3.3",
- "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1",
+ "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0",
"minimist": "1.2.0",
"mocha": "^5.0.1",
"mongojs": "2.4.0",
diff --git a/services/web/public/coffee/directives/asyncForm.coffee b/services/web/public/coffee/directives/asyncForm.coffee
index acafec563d..9baadfd1f2 100644
--- a/services/web/public/coffee/directives/asyncForm.coffee
+++ b/services/web/public/coffee/directives/asyncForm.coffee
@@ -70,7 +70,11 @@ define [
onErrorHandler(httpResponse)
return
- if status == 403 # Forbidden
+ if status == 400 # Bad Request
+ response.message =
+ text: "Invalid Request. Please correct the data and try again."
+ type: 'error'
+ else if status == 403 # Forbidden
response.message =
text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."
type: "error"
diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee
index 1b9eda57d6..12f6dba2ce 100644
--- a/services/web/public/coffee/ide.coffee
+++ b/services/web/public/coffee/ide.coffee
@@ -190,6 +190,23 @@ define [
ide.localStorage = localStorage
ide.browserIsSafari = false
+
+ $scope.switchToFlatLayout = (view) ->
+ $scope.ui.pdfLayout = 'flat'
+ $scope.ui.view = view
+ ide.localStorage "pdf.layout", "flat"
+
+ $scope.switchToSideBySideLayout = (view) ->
+ $scope.ui.pdfLayout = 'sideBySide'
+ $scope.ui.view = view
+ localStorage "pdf.layout", "split"
+
+ if pdfLayout = localStorage("pdf.layout")
+ $scope.switchToSideBySideLayout() if pdfLayout == "split"
+ $scope.switchToFlatLayout() if pdfLayout == "flat"
+ else
+ $scope.switchToSideBySideLayout()
+
try
userAgent = navigator.userAgent
ide.browserIsSafari = (
diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
index 001178c5dd..52295809d5 100644
--- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
+++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee
@@ -71,7 +71,7 @@ define [
autoCompileInterval = null
autoCompileIfReady = () ->
- if $scope.pdf.compiling
+ if $scope.pdf.compiling or !$scope.autocompile_enabled
return
# Only checking linting if syntaxValidation is on and visible to the user
@@ -125,6 +125,9 @@ define [
$scope.uncompiledChanges = false
recalculateUncompiledChanges = () ->
+ if !$scope.autocompile_enabled
+ # Auto-compile was disabled
+ $scope.uncompiledChanges = false
if $scope.ui.pdfHidden
# Don't bother auto-compiling if pdf isn't visible
$scope.uncompiledChanges = false
@@ -588,22 +591,6 @@ define [
{doc, line} = data
ide.editorManager.openDoc(doc, gotoLine: line)
- $scope.switchToFlatLayout = () ->
- $scope.ui.pdfLayout = 'flat'
- $scope.ui.view = 'pdf'
- ide.localStorage "pdf.layout", "flat"
-
- $scope.switchToSideBySideLayout = () ->
- $scope.ui.pdfLayout = 'sideBySide'
- $scope.ui.view = 'editor'
- localStorage "pdf.layout", "split"
-
- if pdfLayout = localStorage("pdf.layout")
- $scope.switchToSideBySideLayout() if pdfLayout == "split"
- $scope.switchToFlatLayout() if pdfLayout == "flat"
- else
- $scope.switchToSideBySideLayout()
-
App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
# enable per-user containers by default
perUserCompile = true
diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee
index fc62c6bc38..49b211b0aa 100644
--- a/services/web/public/coffee/main.coffee
+++ b/services/web/public/coffee/main.coffee
@@ -5,7 +5,7 @@ define [
"main/clear-sessions"
"main/account-upgrade"
"main/plans"
- "main/group-members"
+ "main/user-membership"
"main/scribtex-popup"
"main/event"
"main/bonus"
diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee
index 27124e0951..3d15797940 100644
--- a/services/web/public/coffee/main/project-list/modal-controllers.coffee
+++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee
@@ -2,9 +2,9 @@ define [
"base"
], (App) ->
App.controller 'RenameProjectModalController', ($scope, $modalInstance, $timeout, project, queuedHttp) ->
- $scope.inputs =
+ $scope.inputs =
projectName: project.name
-
+
$scope.state =
inflight: false
error: false
@@ -35,7 +35,7 @@ define [
$modalInstance.dismiss('cancel')
App.controller 'CloneProjectModalController', ($scope, $modalInstance, $timeout, project) ->
- $scope.inputs =
+ $scope.inputs =
projectName: project.name + " (Copy)"
$scope.state =
inflight: false
@@ -66,7 +66,7 @@ define [
$modalInstance.dismiss('cancel')
App.controller 'NewProjectModalController', ($scope, $modalInstance, $timeout, template) ->
- $scope.inputs =
+ $scope.inputs =
projectName: ""
$scope.state =
inflight: false
@@ -123,7 +123,6 @@ define [
$scope.cancel = () ->
$modalInstance.dismiss('cancel')
-
App.controller 'UploadProjectModalController', ($scope, $modalInstance, $timeout) ->
$scope.cancel = () ->
$modalInstance.dismiss('cancel')
@@ -137,3 +136,8 @@ define [
$scope.dismiss = () ->
$modalInstance.dismiss('cancel')
+
+ App.controller 'ShowErrorModalController', ($scope, $modalInstance, error) ->
+ $scope.error = error
+ $scope.cancel = () ->
+ $modalInstance.dismiss('cancel')
diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee
index 9a8d7f7a73..8bdec6f42a 100644
--- a/services/web/public/coffee/main/project-list/project-list.coffee
+++ b/services/web/public/coffee/main/project-list/project-list.coffee
@@ -13,7 +13,7 @@ define [
$scope.predicate = "lastUpdated"
$scope.nUntagged = 0
$scope.reverse = true
- $scope.searchText =
+ $scope.searchText =
value : ""
$timeout () ->
@@ -37,7 +37,7 @@ define [
angular.element($window).bind "resize", () ->
recalculateProjectListHeight()
$scope.$apply()
-
+
# Allow tags to be accessed on projects as well
projectsById = {}
for project in $scope.projects
@@ -56,7 +56,7 @@ define [
tag.selected = true
else
tag.selected = false
-
+
$scope.changePredicate = (newPredicate)->
if $scope.predicate == newPredicate
$scope.reverse = !$scope.reverse
@@ -145,7 +145,7 @@ define [
# We don't want hidden selections
project.selected = false
- localStorage("project_list", JSON.stringify({
+ localStorage("project_list", JSON.stringify({
filter: $scope.filter,
selectedTagId: selectedTag?._id
}))
@@ -461,7 +461,7 @@ define [
resolve:
project: () -> project
)
-
+
if storedUIOpts?.filter?
if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId?
markTagAsSelected(storedUIOpts.selectedTagId)
@@ -505,7 +505,16 @@ define [
$scope.project.isTableActionInflight = true
$scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)")
.then () -> $scope.project.isTableActionInflight = false
- .catch () -> $scope.project.isTableActionInflight = false
+ .catch (response) ->
+ { data, status } = response
+ error = if status == 400 then message: data else true
+ modalInstance = $modal.open(
+ templateUrl: "showErrorModalTemplate"
+ controller: "ShowErrorModalController"
+ resolve:
+ error: () -> error
+ )
+ $scope.project.isTableActionInflight = false
$scope.download = (e) ->
e.stopPropagation()
@@ -535,11 +544,11 @@ define [
url: "/project/#{$scope.project.id}?forever=true"
headers:
"X-CSRF-Token": window.csrfToken
- }).then () ->
+ }).then () ->
$scope.project.isTableActionInflight = false
$scope._removeProjectFromList $scope.project
for tag in $scope.tags
$scope._removeProjectIdsFromTagArray(tag, [ $scope.project.id ])
$scope.updateVisibleProjects()
- .catch () ->
+ .catch () ->
$scope.project.isTableActionInflight = false
diff --git a/services/web/public/coffee/main/subscription-dashboard.coffee b/services/web/public/coffee/main/subscription-dashboard.coffee
index 28d5af013b..7ae01d1b52 100644
--- a/services/web/public/coffee/main/subscription-dashboard.coffee
+++ b/services/web/public/coffee/main/subscription-dashboard.coffee
@@ -36,7 +36,7 @@ define [
$scope.pricing = MultiCurrencyPricing
# $scope.plans = MultiCurrencyPricing.plans
- $scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
+ $scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode]?.symbol
$scope.currencyCode = MultiCurrencyPricing.currencyCode
diff --git a/services/web/public/coffee/main/group-members.coffee b/services/web/public/coffee/main/user-membership.coffee
similarity index 75%
rename from services/web/public/coffee/main/group-members.coffee
rename to services/web/public/coffee/main/user-membership.coffee
index 028639c741..1d2df70407 100644
--- a/services/web/public/coffee/main/group-members.coffee
+++ b/services/web/public/coffee/main/user-membership.coffee
@@ -1,9 +1,10 @@
define [
"base"
], (App) ->
- App.controller "SubscriptionGroupMembersController", ($scope, queuedHttp) ->
+ App.controller "UserMembershipController", ($scope, queuedHttp) ->
$scope.users = window.users
$scope.groupSize = window.groupSize
+ $scope.paths = window.paths
$scope.selectedUsers = []
$scope.inputs =
@@ -22,7 +23,7 @@ define [
emails = parseEmails($scope.inputs.emails)
for email in emails
queuedHttp
- .post("/subscription/invites", {
+ .post(paths.addMember, {
email: email,
_csrf: window.csrfToken
})
@@ -34,10 +35,12 @@ define [
$scope.removeMembers = () ->
for user in $scope.selectedUsers
do (user) ->
- if user.invite and !user._id?
- url = "/subscription/invites/#{encodeURIComponent(user.email)}"
+ if paths.removeInvite and user.invite and !user._id?
+ url = "#{paths.removeInvite}/#{encodeURIComponent(user.email)}"
+ else if paths.removeMember and user._id?
+ url = "#{paths.removeMember}/#{user._id}"
else
- url = "/subscription/group/user/#{user._id}"
+ return
queuedHttp({
method: "DELETE",
url: url
@@ -53,7 +56,7 @@ define [
$scope.updateSelectedUsers = () ->
$scope.selectedUsers = $scope.users.filter (user) -> user.selected
- App.controller "SubscriptionGroupMemberListItemController", ($scope) ->
+ App.controller "UserMembershipListItemController", ($scope) ->
$scope.$watch "user.selected", (value) ->
if value?
$scope.updateSelectedUsers()
diff --git a/services/web/public/stylesheets/app/homepage.less b/services/web/public/stylesheets/app/homepage.less
index 795d04488f..9f60c58cb3 100644
--- a/services/web/public/stylesheets/app/homepage.less
+++ b/services/web/public/stylesheets/app/homepage.less
@@ -94,6 +94,14 @@
border-radius: 9999px;
}
}
+ .hp-register-password-error {
+ margin-bottom: 9px;
+ }
+ .register-banner__password-error {
+ padding: 5px 9px;
+ border: none;
+ border-radius: @btn-border-radius-base;
+ }
.screenshot {
height: 600px;
margin: auto;
diff --git a/services/web/public/stylesheets/app/templates-v2.less b/services/web/public/stylesheets/app/templates-v2.less
index 7f708670b4..8d8358a6b4 100644
--- a/services/web/public/stylesheets/app/templates-v2.less
+++ b/services/web/public/stylesheets/app/templates-v2.less
@@ -16,10 +16,14 @@
}
.cta-links {
- margin-bottom: @margin-md;
-
.cta-link.btn {
- margin-right: @margin-sm;
+ margin: 0 @margin-sm @margin-sm 0;
+ }
+}
+
+.popular-tags {
+ .template-thumbnail {
+ margin: 0 0 1em 0!important;
}
}
@@ -28,25 +32,24 @@
}
.templates-container {
- column-count: 3;
- column-gap: 1em;
+ column-count: 2;
+ column-gap: 2em;
}
.template-thumbnail {
- &.template-thumbnail__container {
- display: inline-block;
- margin: 0 0 1em;
- width: 100%;
- }
+ display: inline-block;
+ margin: 0 0 2em;
+ width: 100%;
.thumbnail {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- margin: 5% 0;
+ margin: 0 0 @margin-sm 0;
padding:0px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
+ width: 100%;
h3 {
color:@link-color;
@@ -67,33 +70,37 @@
.caption__description {
font-style: italic;
- padding: 5px 0;
+ padding: 0 0 5px 0;
.text-overflow();
}
.caption__title {
display: inline-block;
- max-width: 100%;
+ width: 100%;
+ text-align: center;
.text-overflow();
}
+}
- /* Media Queries */
- @media (max-width: @screen-md-min) {
- .thumbnail {
- margin: 5% auto;
- }
+.template-large-pdf-preview {
+ border: solid 1px @gray-lightest;
+ margin-top: @margin-lg;
+}
- .caption .description {
- padding: 5px 50px;
- }
+/* Media Queries */
+@media (min-width: @screen-sm-min) {
+ .templates-container {
+ column-count: 3;
+ column-gap: 3em;
}
}
- .section-tags {
- margin-bottom: @margin-xl;
- margin-top: @margin-md;
- }
-
+@media (min-width: @screen-md-min) {
.template-large-pdf-preview {
- border: solid 1px @gray-lightest;
- margin-bottom: 30px;
- }
\ No newline at end of file
+ margin-top: 0;
+ }
+}
+
+.section-tags {
+ margin-bottom: @margin-xl;
+ margin-top: @margin-md;
+}
\ No newline at end of file
diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
index 78f7b288f6..2f18743a1c 100644
--- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
+++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
@@ -30,6 +30,8 @@ describe "AuthenticationController", ->
revokeAllUserSessions: sinon.stub().callsArgWith(1, null)
"../../infrastructure/Modules": @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, [])}}
"../SudoMode/SudoModeHandler": @SudoModeHandler = {activateSudoMode: sinon.stub().callsArgWith(1, null)}
+ "../Notifications/NotificationsBuilder": @NotificationsBuilder =
+ ipMatcherAffiliation: sinon.stub()
@user =
_id: ObjectId()
email: @email = "USER@example.com"
diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee
index 0a041a0865..39880b112e 100644
--- a/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee
+++ b/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee
@@ -94,6 +94,50 @@ describe "AuthenticationManager", ->
it "should not return a user", ->
@callback.calledWith(null, null).should.equal true
+ describe "validateEmail", ->
+ describe "valid", ->
+ it "should return null", ->
+ result = @AuthenticationManager.validateEmail 'foo@example.com'
+ expect(result).to.equal null
+
+ describe "invalid", ->
+ it "should return validation error object for no email", ->
+ result = @AuthenticationManager.validateEmail ''
+ expect(result).to.not.equal null
+ expect(result.message).to.equal 'email not valid'
+
+ it "should return validation error object for invalid", ->
+ result = @AuthenticationManager.validateEmail 'notanemail'
+ expect(result).to.not.equal null
+ expect(result.message).to.equal 'email not valid'
+
+ describe "validatePassword", ->
+ it "should return null if valid", ->
+ result = @AuthenticationManager.validatePassword 'banana'
+ expect(result).to.equal null
+
+ describe "invalid", ->
+ beforeEach ->
+ @settings.passwordStrengthOptions =
+ length:
+ max:10
+ min:6
+
+ it "should return validation error object if not set", ->
+ result = @AuthenticationManager.validatePassword()
+ expect(result).to.not.equal null
+ expect(result.message).to.equal 'password not set'
+
+ it "should return validation error object if too short", ->
+ result = @AuthenticationManager.validatePassword 'dsd'
+ expect(result).to.not.equal null
+ expect(result.message).to.equal 'password is too short'
+
+ it "should return validation error object if too long", ->
+ result = @AuthenticationManager.validatePassword 'dsdsadsadsadsadsadkjsadjsadjsadljs'
+ expect(result).to.not.equal null
+ expect(result.message).to.equal 'password is too long'
+
describe "setUserPassword", ->
beforeEach ->
@user_id = ObjectId()
diff --git a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
index 103a1f9eea..0d070d687e 100644
--- a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
+++ b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee
@@ -194,7 +194,6 @@ describe "CompileController", ->
.should.equal true
it "should set the content-disposition header with a safe version of the project name", ->
- console.log @res.setContentDisposition.args[0]
@res.setContentDisposition
.calledWith('', filename: "test_nam_.pdf")
.should.equal true
diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
index edd1ce127a..f766feb6dd 100644
--- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee
@@ -301,7 +301,7 @@ describe 'ExportsHandler', ->
@callback.calledWith(null, { body: @body })
.should.equal true
- describe 'fetchZip', ->
+ describe 'fetchDownload', ->
beforeEach (done) ->
@settings.apis =
v1:
@@ -316,7 +316,7 @@ describe 'ExportsHandler', ->
describe "when all goes well", ->
beforeEach (done) ->
@stubRequest.get = @stubGet
- @ExportsHandler.fetchZip @export_id, (error, body) =>
+ @ExportsHandler.fetchDownload @export_id, 'zip', (error, body) =>
@callback(error, body)
done()
diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee
new file mode 100644
index 0000000000..3edb55e09a
--- /dev/null
+++ b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee
@@ -0,0 +1,29 @@
+SandboxedModule = require('sandboxed-module')
+should = require('chai').should()
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+modulePath = "../../../../app/js/Features/Institutions/InstitutionsLocator"
+assert = require("chai").assert
+ObjectId = require('mongoose').Types.ObjectId
+
+describe 'InstitutionsLocator', ->
+ beforeEach ->
+ @user =
+ _id: "5208dd34438842e2db333333"
+ @institution =
+ v1Id: 123
+ managersIds: [ObjectId(), ObjectId()]
+ @Institution =
+ findOne: sinon.stub().yields(null, @institution)
+ @InstitutionsLocator = SandboxedModule.require modulePath, requires:
+ '../../models/Institution': Institution: @Institution
+ "logger-sharelatex": log:->
+
+ describe "finding managed institution", ->
+
+ it "should query the database", (done) ->
+ @InstitutionsLocator.findManagedInstitution @user._id, (err, institution)=>
+ assertCalledWith(@Institution.findOne, { managerIds: @user._id })
+ institution.should.equal @institution
+ done()
diff --git a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
index 3de4546e6d..5c31113552 100644
--- a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee
@@ -289,71 +289,101 @@ describe 'ProjectEntityUpdateHandler', ->
.should.equal true
describe 'addDoc', ->
- beforeEach ->
- @path = "/path/to/doc"
+ describe 'adding a doc', ->
+ beforeEach ->
+ @path = "/path/to/doc"
- @newDoc = _id: doc_id
- @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
- withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
- @ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback
+ @newDoc = _id: doc_id
+ @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
+ withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
+ @ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback
- it "creates the doc without history", () ->
- @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock
- .calledWith(project_id, folder_id, @docName, @docLines, userId)
- .should.equal true
+ it "creates the doc without history", () ->
+ @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock
+ .calledWith(project_id, folder_id, @docName, @docLines, userId)
+ .should.equal true
- it "sends the change in project structure to the doc updater", () ->
- newDocs = [
- doc: @newDoc
- path: @path
- docLines: @docLines.join('\n')
- ]
- @DocumentUpdaterHandler.updateProjectStructure
- .calledWith(project_id, projectHistoryId, userId, {newDocs})
- .should.equal true
+ it "sends the change in project structure to the doc updater", () ->
+ newDocs = [
+ doc: @newDoc
+ path: @path
+ docLines: @docLines.join('\n')
+ ]
+ @DocumentUpdaterHandler.updateProjectStructure
+ .calledWith(project_id, projectHistoryId, userId, {newDocs})
+ .should.equal true
+
+ describe 'adding a doc with an invalid name', ->
+ beforeEach ->
+ @path = "/path/to/doc"
+
+ @newDoc = _id: doc_id
+ @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory =
+ withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project)
+ @ProjectEntityUpdateHandler.addDoc project_id, folder_id, "*" + @docName, @docLines, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'addFile', ->
- beforeEach ->
- @path = "/path/to/file"
+ describe 'adding a file', ->
+ beforeEach ->
+ @path = "/path/to/file"
- @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
- @TpdsUpdateSender.addFile = sinon.stub().yields()
- @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
- @ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+ @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
+ @TpdsUpdateSender.addFile = sinon.stub().yields()
+ @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
- it "updates the file in the filestore", () ->
- @FileStoreHandler.uploadFileFromDisk
- .calledWith(project_id, file_id, @fileSystemPath)
- .should.equal true
+ it "updates the file in the filestore", () ->
+ @FileStoreHandler.uploadFileFromDisk
+ .calledWith(project_id, file_id, @fileSystemPath)
+ .should.equal true
- it "updates the file in mongo", () ->
- fileMatcher = sinon.match (file) =>
- file.name == @fileName
+ it "updates the file in mongo", () ->
+ fileMatcher = sinon.match (file) =>
+ file.name == @fileName
- @ProjectEntityMongoUpdateHandler.addFile
- .calledWithMatch(project_id, folder_id, fileMatcher)
- .should.equal true
+ @ProjectEntityMongoUpdateHandler.addFile
+ .calledWithMatch(project_id, folder_id, fileMatcher)
+ .should.equal true
- it "notifies the tpds", () ->
- @TpdsUpdateSender.addFile
- .calledWith({
- project_id: project_id
- project_name: @project.name
- file_id: file_id
- rev: 0
+ it "notifies the tpds", () ->
+ @TpdsUpdateSender.addFile
+ .calledWith({
+ project_id: project_id
+ project_name: @project.name
+ file_id: file_id
+ rev: 0
+ path: @path
+ })
+ .should.equal true
+
+ it "sends the change in project structure to the doc updater", () ->
+ newFiles = [
+ file: @newFile
path: @path
- })
- .should.equal true
+ url: @fileUrl
+ ]
+ @DocumentUpdaterHandler.updateProjectStructure
+ .calledWith(project_id, projectHistoryId, userId, {newFiles})
+ .should.equal true
- it "sends the change in project structure to the doc updater", () ->
- newFiles = [
- file: @newFile
- path: @path
- url: @fileUrl
- ]
- @DocumentUpdaterHandler.updateProjectStructure
- .calledWith(project_id, projectHistoryId, userId, {newFiles})
- .should.equal true
+ describe 'adding a file with an invalid name', ->
+ beforeEach ->
+ @path = "/path/to/file"
+
+ @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData}
+ @TpdsUpdateSender.addFile = sinon.stub().yields()
+ @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addFile project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'replaceFile', ->
beforeEach ->
@@ -404,83 +434,116 @@ describe 'ProjectEntityUpdateHandler', ->
.should.equal true
describe 'addDocWithoutUpdatingHistory', ->
- beforeEach ->
- @path = "/path/to/doc"
+ describe 'adding a doc', ->
+ beforeEach ->
+ @path = "/path/to/doc"
- @project = _id: project_id, name: 'some project'
+ @project = _id: project_id, name: 'some project'
- @TpdsUpdateSender.addDoc = sinon.stub().yields()
- @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
- @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
- @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback
+ @TpdsUpdateSender.addDoc = sinon.stub().yields()
+ @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
+ @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback
- it "updates the doc in the docstore", () ->
- @DocstoreManager.updateDoc
- .calledWith(project_id, doc_id, @docLines, 0, {})
- .should.equal true
+ it "updates the doc in the docstore", () ->
+ @DocstoreManager.updateDoc
+ .calledWith(project_id, doc_id, @docLines, 0, {})
+ .should.equal true
- it "updates the doc in mongo", () ->
- docMatcher = sinon.match (doc) =>
- doc.name == @docName
+ it "updates the doc in mongo", () ->
+ docMatcher = sinon.match (doc) =>
+ doc.name == @docName
- @ProjectEntityMongoUpdateHandler.addDoc
- .calledWithMatch(project_id, folder_id, docMatcher)
- .should.equal true
+ @ProjectEntityMongoUpdateHandler.addDoc
+ .calledWithMatch(project_id, folder_id, docMatcher)
+ .should.equal true
- it "notifies the tpds", () ->
- @TpdsUpdateSender.addDoc
- .calledWith({
- project_id: project_id
- project_name: @project.name
- doc_id: doc_id
- rev: 0
- path: @path
- })
- .should.equal true
+ it "notifies the tpds", () ->
+ @TpdsUpdateSender.addDoc
+ .calledWith({
+ project_id: project_id
+ project_name: @project.name
+ doc_id: doc_id
+ rev: 0
+ path: @path
+ })
+ .should.equal true
- it "should not should send the change in project structure to the doc updater", () ->
- @DocumentUpdaterHandler.updateProjectStructure
- .called
- .should.equal false
+ it "should not should send the change in project structure to the doc updater", () ->
+ @DocumentUpdaterHandler.updateProjectStructure
+ .called
+ .should.equal false
+
+ describe 'adding a doc with an invalid name', ->
+ beforeEach ->
+ @path = "/path/to/doc"
+
+ @project = _id: project_id, name: 'some project'
+
+ @TpdsUpdateSender.addDoc = sinon.stub().yields()
+ @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5)
+ @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, "*" + @docName, @docLines, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'addFileWithoutUpdatingHistory', ->
- beforeEach ->
- @path = "/path/to/file"
+ describe 'adding a file', ->
+ beforeEach ->
+ @path = "/path/to/file"
- @project = _id: project_id, name: 'some project'
+ @project = _id: project_id, name: 'some project'
- @TpdsUpdateSender.addFile = sinon.stub().yields()
- @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
- @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, userId, @callback
+ @TpdsUpdateSender.addFile = sinon.stub().yields()
+ @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback
- it "updates the file in the filestore", () ->
- @FileStoreHandler.uploadFileFromDisk
- .calledWith(project_id, file_id, @fileSystemPath)
- .should.equal true
+ it "updates the file in the filestore", () ->
+ @FileStoreHandler.uploadFileFromDisk
+ .calledWith(project_id, file_id, @fileSystemPath)
+ .should.equal true
- it "updates the file in mongo", () ->
- fileMatcher = sinon.match (file) =>
- file.name == @fileName
+ it "updates the file in mongo", () ->
+ fileMatcher = sinon.match (file) =>
+ file.name == @fileName
- @ProjectEntityMongoUpdateHandler.addFile
- .calledWithMatch(project_id, folder_id, fileMatcher)
- .should.equal true
+ @ProjectEntityMongoUpdateHandler.addFile
+ .calledWithMatch(project_id, folder_id, fileMatcher)
+ .should.equal true
- it "notifies the tpds", () ->
- @TpdsUpdateSender.addFile
- .calledWith({
- project_id: project_id
- project_name: @project.name
- file_id: file_id
- rev: 0
- path: @path
- })
- .should.equal true
+ it "notifies the tpds", () ->
+ @TpdsUpdateSender.addFile
+ .calledWith({
+ project_id: project_id
+ project_name: @project.name
+ file_id: file_id
+ rev: 0
+ path: @path
+ })
+ .should.equal true
- it "should not should send the change in project structure to the doc updater", () ->
- @DocumentUpdaterHandler.updateProjectStructure
- .called
- .should.equal false
+ it "should not should send the change in project structure to the doc updater", () ->
+ @DocumentUpdaterHandler.updateProjectStructure
+ .called
+ .should.equal false
+
+ describe 'adding a file with an invalid name', ->
+ beforeEach ->
+ @path = "/path/to/file"
+
+ @project = _id: project_id, name: 'some project'
+
+ @TpdsUpdateSender.addFile = sinon.stub().yields()
+ @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project)
+ @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'upsertDoc', ->
describe 'upserting into an invalid folder', ->
@@ -543,6 +606,20 @@ describe 'ProjectEntityUpdateHandler', ->
it 'returns the doc', ->
@callback.calledWith(null, @newDoc, true)
+ describe 'upserting a new doc with an invalid name', ->
+ beforeEach ->
+ @folder = _id: folder_id, docs: []
+ @newDoc = _id: doc_id
+ @ProjectLocator.findElement = sinon.stub().yields(null, @folder)
+ @ProjectEntityUpdateHandler.addDoc = withoutLock: sinon.stub().yields(null, @newDoc)
+
+ @ProjectEntityUpdateHandler.upsertDoc project_id, folder_id, "*" + @docName, @docLines, @source, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
+
describe 'upsertFile', ->
describe 'upserting into an invalid folder', ->
beforeEach ->
@@ -593,63 +670,155 @@ describe 'ProjectEntityUpdateHandler', ->
it 'returns the file', ->
@callback.calledWith(null, @newFile, true)
+ describe 'upserting a new file with an invalid name', ->
+ beforeEach ->
+ @folder = _id: folder_id, fileRefs: []
+ @newFile = _id: file_id
+ @ProjectLocator.findElement = sinon.stub().yields(null, @folder)
+ @ProjectEntityUpdateHandler.addFile = mainTask: sinon.stub().yields(null, @newFile)
+
+ @ProjectEntityUpdateHandler.upsertFile project_id, folder_id, '*' + @fileName, @fileSystemPath, @linkedFileData, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
+
describe 'upsertDocWithPath', ->
- beforeEach ->
- @path = "/folder/doc.tex"
- @newFolders = [ 'mock-a', 'mock-b' ]
- @folder = _id: folder_id
- @doc = _id: doc_id
- @isNewDoc = true
- @ProjectEntityUpdateHandler.mkdirp =
- withoutLock: sinon.stub().yields(null, @newFolders, @folder)
- @ProjectEntityUpdateHandler.upsertDoc =
- withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+ describe 'upserting a doc', ->
+ beforeEach ->
+ @path = "/folder/doc.tex"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @doc = _id: doc_id
+ @isNewDoc = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertDoc =
+ withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
- @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+ @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
- it 'creates any necessary folders', ->
- @ProjectEntityUpdateHandler.mkdirp.withoutLock
- .calledWith(project_id, '/folder')
- .should.equal true
+ it 'creates any necessary folders', ->
+ @ProjectEntityUpdateHandler.mkdirp.withoutLock
+ .calledWith(project_id, '/folder')
+ .should.equal true
- it 'upserts the doc', ->
- @ProjectEntityUpdateHandler.upsertDoc.withoutLock
- .calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId)
- .should.equal true
+ it 'upserts the doc', ->
+ @ProjectEntityUpdateHandler.upsertDoc.withoutLock
+ .calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId)
+ .should.equal true
- it 'calls the callback', ->
- @callback
- .calledWith(null, @doc, @isNewDoc, @newFolders, @folder)
- .should.equal true
+ it 'calls the callback', ->
+ @callback
+ .calledWith(null, @doc, @isNewDoc, @newFolders, @folder)
+ .should.equal true
+
+ describe 'upserting a doc with an invalid path', ->
+ beforeEach ->
+ @path = "/*folder/doc.tex"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @doc = _id: doc_id
+ @isNewDoc = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertDoc =
+ withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+
+ @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
+
+ describe 'upserting a doc with an invalid name', ->
+ beforeEach ->
+ @path = "/folder/*doc.tex"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @doc = _id: doc_id
+ @isNewDoc = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertDoc =
+ withoutLock: sinon.stub().yields(null, @doc, @isNewDoc)
+
+ @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'upsertFileWithPath', ->
- beforeEach ->
- @path = "/folder/file.png"
- @newFolders = [ 'mock-a', 'mock-b' ]
- @folder = _id: folder_id
- @file = _id: file_id
- @isNewFile = true
- @ProjectEntityUpdateHandler.mkdirp =
- withoutLock: sinon.stub().yields(null, @newFolders, @folder)
- @ProjectEntityUpdateHandler.upsertFile =
- mainTask: sinon.stub().yields(null, @file, @isNewFile)
+ describe 'upserting a file', ->
+ beforeEach ->
+ @path = "/folder/file.png"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @file = _id: file_id
+ @isNewFile = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertFile =
+ mainTask: sinon.stub().yields(null, @file, @isNewFile)
- @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+ @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
- it 'creates any necessary folders', ->
- @ProjectEntityUpdateHandler.mkdirp.withoutLock
- .calledWith(project_id, '/folder')
- .should.equal true
+ it 'creates any necessary folders', ->
+ @ProjectEntityUpdateHandler.mkdirp.withoutLock
+ .calledWith(project_id, '/folder')
+ .should.equal true
- it 'upserts the file', ->
- @ProjectEntityUpdateHandler.upsertFile.mainTask
- .calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId)
- .should.equal true
+ it 'upserts the file', ->
+ @ProjectEntityUpdateHandler.upsertFile.mainTask
+ .calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId)
+ .should.equal true
- it 'calls the callback', ->
- @callback
- .calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder)
- .should.equal true
+ it 'calls the callback', ->
+ @callback
+ .calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder)
+ .should.equal true
+
+ describe 'upserting a file with an invalid path', ->
+ beforeEach ->
+ @path = "/*folder/file.png"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @file = _id: file_id
+ @isNewFile = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertFile =
+ mainTask: sinon.stub().yields(null, @file, @isNewFile)
+
+ @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
+
+ describe 'upserting a file with an invalid name', ->
+ beforeEach ->
+ @path = "/folder/*file.png"
+ @newFolders = [ 'mock-a', 'mock-b' ]
+ @folder = _id: folder_id
+ @file = _id: file_id
+ @isNewFile = true
+ @ProjectEntityUpdateHandler.mkdirp =
+ withoutLock: sinon.stub().yields(null, @newFolders, @folder)
+ @ProjectEntityUpdateHandler.upsertFile =
+ mainTask: sinon.stub().yields(null, @file, @isNewFile)
+
+ @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'deleteEntity', ->
beforeEach ->
@@ -721,16 +890,29 @@ describe 'ProjectEntityUpdateHandler', ->
.should.equal true
describe 'addFolder', ->
- beforeEach ->
- @parentFolder_id = '123asdf'
- @folderName = 'new-folder'
- @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
- @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
+ describe 'adding a folder', ->
+ beforeEach ->
+ @parentFolder_id = '123asdf'
+ @folderName = 'new-folder'
+ @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
+ @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
- it 'calls ProjectEntityMongoUpdateHandler', ->
- @ProjectEntityMongoUpdateHandler.addFolder
- .calledWith(project_id, @parentFolder_id, @folderName)
- .should.equal true
+ it 'calls ProjectEntityMongoUpdateHandler', ->
+ @ProjectEntityMongoUpdateHandler.addFolder
+ .calledWith(project_id, @parentFolder_id, @folderName)
+ .should.equal true
+
+ describe 'adding a folder with an invalid name', ->
+ beforeEach ->
+ @parentFolder_id = '123asdf'
+ @folderName = '*new-folder'
+ @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields()
+ @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe 'moveEntity', ->
beforeEach ->
@@ -763,35 +945,57 @@ describe 'ProjectEntityUpdateHandler', ->
.should.equal true
describe "renameEntity", ->
- beforeEach ->
- @project_name = 'project name'
- @startPath = '/folder/a.tex'
- @endPath = '/folder/b.tex'
- @rev = 2
- @changes = newDocs: ['old-doc'], newFiles: ['old-file']
- @newDocName = 'b.tex'
- @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
- null, @project, @startPath, @endPath, @rev, @changes
- )
- @TpdsUpdateSender.moveEntity = sinon.stub()
- @DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
+ describe 'renaming an entity', ->
+ beforeEach ->
+ @project_name = 'project name'
+ @startPath = '/folder/a.tex'
+ @endPath = '/folder/b.tex'
+ @rev = 2
+ @changes = newDocs: ['old-doc'], newFiles: ['old-file']
+ @newDocName = 'b.tex'
+ @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
+ null, @project, @startPath, @endPath, @rev, @changes
+ )
+ @TpdsUpdateSender.moveEntity = sinon.stub()
+ @DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
- @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
+ @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
- it 'moves the entity in mongo', ->
- @ProjectEntityMongoUpdateHandler.renameEntity
- .calledWith(project_id, doc_id, 'doc', @newDocName)
- .should.equal true
+ it 'moves the entity in mongo', ->
+ @ProjectEntityMongoUpdateHandler.renameEntity
+ .calledWith(project_id, doc_id, 'doc', @newDocName)
+ .should.equal true
- it 'notifies tpds', ->
- @TpdsUpdateSender.moveEntity
- .calledWith({project_id, @project_name, @startPath, @endPath, @rev})
- .should.equal true
+ it 'notifies tpds', ->
+ @TpdsUpdateSender.moveEntity
+ .calledWith({project_id, @project_name, @startPath, @endPath, @rev})
+ .should.equal true
- it 'sends the changes in project structure to the doc updater', ->
- @DocumentUpdaterHandler.updateProjectStructure
- .calledWith(project_id, projectHistoryId, userId, @changes, @callback)
- .should.equal true
+ it 'sends the changes in project structure to the doc updater', ->
+ @DocumentUpdaterHandler.updateProjectStructure
+ .calledWith(project_id, projectHistoryId, userId, @changes, @callback)
+ .should.equal true
+
+ describe 'renaming an entity to an invalid name', ->
+ beforeEach ->
+ @project_name = 'project name'
+ @startPath = '/folder/a.tex'
+ @endPath = '/folder/b.tex'
+ @rev = 2
+ @changes = newDocs: ['old-doc'], newFiles: ['old-file']
+ @newDocName = '*b.tex'
+ @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields(
+ null, @project, @startPath, @endPath, @rev, @changes
+ )
+ @TpdsUpdateSender.moveEntity = sinon.stub()
+ @DocumentUpdaterHandler.updateProjectStructure = sinon.stub()
+
+ @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback
+
+ it 'returns an error', ->
+ errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError)
+ @callback.calledWithMatch(errorMatcher)
+ .should.equal true
describe "resyncProjectHistory", ->
describe "a deleted project", ->
@@ -998,5 +1202,3 @@ describe 'ProjectEntityUpdateHandler', ->
it "should call the callback", ->
@callback.called.should.equal true
-
-
diff --git a/services/web/test/unit/coffee/Project/SafePathTests.coffee b/services/web/test/unit/coffee/Project/SafePathTests.coffee
index 6e0c55a5bc..afea03fdea 100644
--- a/services/web/test/unit/coffee/Project/SafePathTests.coffee
+++ b/services/web/test/unit/coffee/Project/SafePathTests.coffee
@@ -83,6 +83,59 @@ describe 'SafePath', ->
result = @SafePath.isCleanFilename 'foo\\bar'
result.should.equal false
+ describe 'isCleanPath', ->
+ it 'should accept a valid filename "main.tex"', ->
+ result = @SafePath.isCleanPath 'main.tex'
+ result.should.equal true
+
+ it 'should accept a valid path "foo/main.tex"', ->
+ result = @SafePath.isCleanPath 'foo/main.tex'
+ result.should.equal true
+
+ it 'should accept empty path elements', ->
+ result = @SafePath.isCleanPath 'foo//main.tex'
+ result.should.equal true
+
+ it 'should not accept an empty filename', ->
+ result = @SafePath.isCleanPath 'foo/bar/'
+ result.should.equal false
+
+ it 'should accept a path that starts with a slash', ->
+ result = @SafePath.isCleanPath '/etc/passwd'
+ result.should.equal true
+
+ it 'should not accept a path that has an asterisk as the 0th element', ->
+ result = @SafePath.isCleanPath '*/foo/bar'
+ result.should.equal false
+
+ it 'should not accept a path that has an asterisk as a middle element', ->
+ result = @SafePath.isCleanPath 'foo/*/bar'
+ result.should.equal false
+
+ it 'should not accept a path that has an asterisk as the filename', ->
+ result = @SafePath.isCleanPath 'foo/bar/*'
+ result.should.equal false
+
+ it 'should not accept a path that contains an asterisk in the 0th element', ->
+ result = @SafePath.isCleanPath 'f*o/bar/baz'
+ result.should.equal false
+
+ it 'should not accept a path that contains an asterisk in a middle element', ->
+ result = @SafePath.isCleanPath 'foo/b*r/baz'
+ result.should.equal false
+
+ it 'should not accept a path that contains an asterisk in the filename', ->
+ result = @SafePath.isCleanPath 'foo/bar/b*z'
+ result.should.equal false
+
+ it 'should not accept multiple problematic elements', ->
+ result = @SafePath.isCleanPath 'f*o/b*r/b*z'
+ result.should.equal false
+
+ it 'should not accept a problematic path with an empty element', ->
+ result = @SafePath.isCleanPath 'foo//*/bar'
+ result.should.equal false
+
describe 'isAllowedLength', ->
it 'should accept a valid path "main.tex"', ->
result = @SafePath.isAllowedLength 'main.tex'
@@ -96,7 +149,7 @@ describe 'SafePath', ->
it 'should not accept an empty path', ->
result = @SafePath.isAllowedLength ''
result.should.equal false
-
+
describe 'clean', ->
it 'should not modify a valid filename', ->
result = @SafePath.clean 'main.tex'
@@ -105,7 +158,7 @@ describe 'SafePath', ->
it 'should replace invalid characters with _', ->
result = @SafePath.clean 'foo/bar*/main.tex'
result.should.equal 'foo_bar__main.tex'
-
+
it 'should replace "." with "_"', ->
result = @SafePath.clean '.'
result.should.equal '_'
@@ -133,7 +186,7 @@ describe 'SafePath', ->
it 'should prefix javascript property names with @', ->
result = @SafePath.clean 'prototype'
result.should.equal '@prototype'
-
+
it 'should prefix javascript property names in the prototype with @', ->
result = @SafePath.clean 'hasOwnProperty'
result.should.equal '@hasOwnProperty'
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
index a399496399..91706130f3 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
+++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
@@ -81,25 +81,6 @@ describe "SubscriptionGroupController", ->
done()
@Controller.removeUserFromGroup @req, res
- describe "renderSubscriptionGroupAdminPage", ->
- it "should redirect you if you don't have a group account", (done)->
- @subscription.groupPlan = false
-
- res =
- redirect : (path)=>
- path.should.equal("/user/subscription")
- done()
- @Controller.renderSubscriptionGroupAdminPage @req, res
-
- it "should redirect you don't have a subscription", (done)->
- @SubscriptionLocator.getUsersSubscription = sinon.stub().callsArgWith(1)
-
- res =
- redirect : (path)=>
- path.should.equal("/user/subscription")
- done()
- @Controller.renderSubscriptionGroupAdminPage @req, res
-
describe "exportGroupCsv", ->
beforeEach ->
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
index 78f6ac3805..0f2290f014 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
+++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
@@ -157,52 +157,6 @@ describe "SubscriptionGroupHandler", ->
{ $pull: { member_ids: @oldId } }
).should.equal true
- describe "getPopulatedListOfMembers", ->
- beforeEach ->
- @subscription = {}
- @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription)
- @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
-
- it "should locate the subscription", (done)->
- @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
- @Handler.getPopulatedListOfMembers @subscriptionId, (err, users)=>
- @SubscriptionLocator.getSubscription.calledWith(@subscriptionId).should.equal true
- done()
-
- it "should get the users by id", (done)->
- @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"})
- @subscription.member_ids = ["1234", "342432", "312312"]
- @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
- @UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true
- @UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true
- @UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true
- users.length.should.equal @subscription.member_ids.length
- done()
-
- it "should just return the id if the user can not be found as they may have deleted their account", (done)->
- @UserGetter.getUser.callsArgWith(1)
- @subscription.member_ids = ["1234", "342432", "312312"]
- @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
- assert.deepEqual users[0], {_id:@subscription.member_ids[0]}
- assert.deepEqual users[1], {_id:@subscription.member_ids[1]}
- assert.deepEqual users[2], {_id:@subscription.member_ids[2]}
- done()
-
- it "should return any invited users", (done) ->
- @subscription.invited_emails = [ "jo@example.com" ]
-
- @subscription.teamInvites = [
- { email: "charlie@example.com" }
- ]
-
- @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=>
- users[0].email.should.equal "jo@example.com"
- users[0].invite.should.equal true
- users[1].email.should.equal "charlie@example.com"
- users[1].invite.should.equal true
- users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length
- done()
-
describe "isUserPartOfGroup", ->
beforeEach ->
@subscription_id = "123ed13123"
diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
index a66d7f5dec..1a67a183b4 100644
--- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
+++ b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
@@ -17,6 +17,7 @@ describe "SubscriptionUpdater", ->
_id: @adminuser_id = "5208dd34438843e2db000007"
@otherUserId = "5208dd34438842e2db000005"
@allUserIds = ["13213", "dsadas", "djsaiud89"]
+ @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com'
@subscription = subscription =
_id: "111111111111111111111111"
admin_id: @adminUser._id
@@ -67,6 +68,7 @@ describe "SubscriptionUpdater", ->
getUsers: (memberIds, projection, callback) ->
users = memberIds.map (id) -> { _id: id }
callback(null, users)
+ getUserOrUserStubById: sinon.stub()
@ReferalFeatures = getBonusFeatures: sinon.stub().callsArgWith(1)
@Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}}
@@ -190,6 +192,7 @@ describe "SubscriptionUpdater", ->
describe "removeUserFromGroup", ->
beforeEach ->
@FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1)
+ @UserGetter.getUserOrUserStubById.yields(null, {}, false)
it "should pull the users id from the group", (done)->
@SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, =>
@@ -205,6 +208,12 @@ describe "SubscriptionUpdater", ->
@FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true
done()
+ it "should not update features for user stubs", (done)->
+ @UserGetter.getUserOrUserStubById.yields(null, {}, true)
+ @SubscriptionUpdater.removeUserFromGroup @subscription._id, @userStub._id, =>
+ @FeaturesUpdater.refreshFeatures.called.should.equal false
+ done()
+
describe "deleteSubscription", ->
beforeEach (done) ->
@subscription_id = ObjectId().toString()
diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
index a08789ede9..1e61b14c0b 100644
--- a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
+++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee
@@ -59,7 +59,7 @@ describe 'TemplatesController', ->
"uuid":v4:=>@uuid
"request": @request
"fs":@fs
- "../../../../app/js/models/Project": {Project: @Project}
+ "../../../js/models/Project": {Project: @Project}
@zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex"
@templateName = "project name here"
@user_id = "1234"
diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
index f8bcce30ce..7fcd2147a5 100644
--- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
+++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee
@@ -19,6 +19,8 @@ describe "UserRegistrationHandler", ->
@UserCreator =
createNewUser:sinon.stub().callsArgWith(1, null, @user)
@AuthenticationManager =
+ validateEmail: sinon.stub().returns(null)
+ validatePassword: sinon.stub().returns(null)
setUserPassword: sinon.stub().callsArgWith(2)
@NewsLetterManager =
subscribe: sinon.stub().callsArgWith(1)
@@ -44,28 +46,25 @@ describe "UserRegistrationHandler", ->
describe 'validate Register Request', ->
-
-
- it 'allow working account through', ->
+ it 'allows passing validation through', ->
result = @handler._registrationRequestIsValid @passingRequest
result.should.equal true
-
- it 'not allow not valid email through ', ()->
- @passingRequest.email = "notemail"
- result = @handler._registrationRequestIsValid @passingRequest
- result.should.equal false
- it 'not allow no email through ', ->
- @passingRequest.email = ""
- result = @handler._registrationRequestIsValid @passingRequest
- result.should.equal false
-
- it 'not allow no password through ', ()->
- @passingRequest.password= ""
- result = @handler._registrationRequestIsValid @passingRequest
- result.should.equal false
+ describe 'failing email validation', ->
+ beforeEach ->
+ @AuthenticationManager.validateEmail.returns({ message: 'email not set' })
+ it 'does not allow through', ->
+ result = @handler._registrationRequestIsValid @passingRequest
+ result.should.equal false
+ describe 'failing password validation', ->
+ beforeEach ->
+ @AuthenticationManager.validatePassword.returns({ message: 'password is too short' })
+
+ it 'does not allow through', ->
+ result = @handler._registrationRequestIsValid @passingRequest
+ result.should.equal false
describe "registerNewUser", ->
diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee
new file mode 100644
index 0000000000..f890abfefa
--- /dev/null
+++ b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee
@@ -0,0 +1,95 @@
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+chai = require('chai')
+should = chai.should()
+assert = chai.assert
+expect = require('chai').expect
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipController.js"
+SandboxedModule = require('sandboxed-module')
+MockRequest = require "../helpers/MockRequest"
+MockResponse = require "../helpers/MockResponse"
+
+describe "UserMembershipController", ->
+ beforeEach ->
+ @req = new MockRequest()
+ @user = _id: 'mock-user-id'
+ @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar'
+ @subscription = { _id: 'mock-subscription-id'}
+ @users = [{ _id: 'mock-member-id-1' }, { _id: 'mock-member-id-2' }]
+
+ @AuthenticationController =
+ getLoggedInUserId: sinon.stub().returns(@user._id)
+ @UserMembershipHandler =
+ getEntity: sinon.stub().yields(null, @subscription)
+ getUsers: sinon.stub().yields(null, @users)
+ addUser: sinon.stub().yields(null, @newUser)
+ removeUser: sinon.stub().yields(null)
+ @UserMembershipController = SandboxedModule.require modulePath, requires:
+ '../Authentication/AuthenticationController': @AuthenticationController
+ './UserMembershipHandler': @UserMembershipHandler
+ "logger-sharelatex":
+ log: ->
+ err: ->
+
+ describe 'index', ->
+ it 'get entity', (done) ->
+ @UserMembershipController.index 'group', @req, render: () =>
+ sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'group', @user._id)
+ done()
+
+ it 'get users', (done) ->
+ @UserMembershipController.index 'group', @req, render: () =>
+ sinon.assert.calledWith(@UserMembershipHandler.getUsers, 'group', @subscription)
+ done()
+
+ it 'render group view', (done) ->
+ @UserMembershipController.index 'group', @req, render: (viewPath, viewParams) =>
+ expect(viewPath).to.equal 'user_membership/index'
+ expect(viewParams.entity).to.deep.equal @subscription
+ expect(viewParams.users).to.deep.equal @users
+ expect(viewParams.translations.title).to.equal 'group_account'
+ expect(viewParams.paths.addMember).to.equal '/subscription/invites'
+ done()
+
+ it 'render group managers view', (done) ->
+ @UserMembershipController.index 'groupManagers', @req, render: (viewPath, viewParams) =>
+ expect(viewPath).to.equal 'user_membership/index'
+ expect(viewParams.translations.title).to.equal 'group_managers'
+ expect(viewParams.paths.exportMembers).to.be.undefined
+ done()
+
+ it 'render institution view', (done) ->
+ @UserMembershipController.index 'institution', @req, render: (viewPath, viewParams) =>
+ expect(viewPath).to.equal 'user_membership/index'
+ expect(viewParams.translations.title).to.equal 'institution_managers'
+ expect(viewParams.paths.exportMembers).to.be.undefined
+ done()
+
+ describe 'add', ->
+ beforeEach ->
+ @req.body.email = @newUser.email
+
+ it 'get entity', (done) ->
+ @UserMembershipController.add 'groupManagers', @req, json: () =>
+ sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'groupManagers', @user._id)
+ done()
+
+ it 'add user', (done) ->
+ @UserMembershipController.add 'groupManagers', @req, json: () =>
+ sinon.assert.calledWith(@UserMembershipHandler.addUser, 'groupManagers', @subscription, @newUser.email)
+ done()
+
+ it 'return user object', (done) ->
+ @UserMembershipController.add 'groupManagers', @req, json: (payload) =>
+ payload.user.should.equal @newUser
+ done()
+
+ describe 'remove', ->
+ beforeEach ->
+ @req.params.userId = @newUser._id
+
+ it 'remove user', (done) ->
+ @UserMembershipController.remove 'groupManagers', @req, send: () =>
+ sinon.assert.calledWith(@UserMembershipHandler.removeUser, 'groupManagers', @subscription, @newUser._id)
+ done()
diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee
new file mode 100644
index 0000000000..23598a82a1
--- /dev/null
+++ b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee
@@ -0,0 +1,172 @@
+chai = require('chai')
+should = chai.should()
+expect = require('chai').expect
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+ObjectId = require("../../../../app/js/infrastructure/mongojs").ObjectId
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipHandler"
+SandboxedModule = require("sandboxed-module")
+Errors = require("../../../../app/js/Features/Errors/Errors")
+
+describe 'UserMembershipHandler', ->
+ beforeEach ->
+ @user = _id: 'mock-user-id'
+ @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar'
+ @subscription =
+ _id: 'mock-subscription-id'
+ groupPlan: true
+ membersLimit: 10
+ member_ids: [ObjectId(), ObjectId()]
+ manager_ids: [ObjectId()]
+ invited_emails: ['mock-email-1@foo.com']
+ teamInvites: [{ email: 'mock-email-1@bar.com' }]
+ update: sinon.stub().yields(null)
+ @institution =
+ _id: 'mock-institution-id'
+ v1Id: 123
+ managerIds: [ObjectId(), ObjectId(), ObjectId()]
+ update: sinon.stub().yields(null)
+
+ @SubscriptionLocator =
+ findManagedSubscription: sinon.stub().yields(null, @subscription)
+ @InstitutionsLocator =
+ findManagedInstitution: sinon.stub().yields(null, @institution)
+ @UserMembershipViewModel =
+ buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'})
+ build: sinon.stub().returns(@newUser)
+ @UserGetter =
+ getUserByAnyEmail: sinon.stub().yields(null, @newUser)
+ @UserMembershipHandler = SandboxedModule.require modulePath, requires:
+ '../Subscription/SubscriptionLocator': @SubscriptionLocator
+ '../Institutions/InstitutionsLocator': @InstitutionsLocator
+ './UserMembershipViewModel': @UserMembershipViewModel
+ '../User/UserGetter': @UserGetter
+ '../Errors/Errors': Errors
+ 'logger-sharelatex':
+ log: ->
+ err: ->
+
+ describe 'getEntty', ->
+ it 'validate type', (done) ->
+ @UserMembershipHandler.getEntity 'foo', null, (error) ->
+ should.exist(error)
+ expect(error.message).to.match /No such entity/
+ done()
+
+ describe 'group subscriptions', ->
+ it 'get subscription', (done) ->
+ @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) =>
+ should.not.exist(error)
+ assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id)
+ expect(subscription).to.equal @subscription
+ expect(subscription.membersLimit).to.equal 10
+ done()
+
+ it 'check subscription is a group', (done) ->
+ @SubscriptionLocator.findManagedSubscription.yields(null, { groupPlan: false })
+ @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) ->
+ should.exist(error)
+ done()
+
+ it 'handle error', (done) ->
+ @SubscriptionLocator.findManagedSubscription.yields(new Error('some error'))
+ @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) =>
+ should.exist(error)
+ done()
+
+ describe 'group managers', ->
+ it 'has no members limit', (done) ->
+ @UserMembershipHandler.getEntity 'groupManagers', @user._id, (error, subscription) =>
+ should.not.exist(error)
+ assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id)
+ expect(subscription.membersLimit).to.equal null
+ done()
+
+ describe 'institutions', ->
+ it 'get institution', (done) ->
+ @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+ should.not.exist(error)
+ assertCalledWith(@InstitutionsLocator.findManagedInstitution, @user._id)
+ expect(institution).to.equal @institution
+ done()
+
+ it 'handle institution not found', (done) ->
+ @InstitutionsLocator.findManagedInstitution.yields(null, null)
+ @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+ should.exist(error)
+ expect(error).to.be.an.instanceof(Errors.NotFoundError)
+ done()
+
+ it 'handle errors', (done) ->
+ @InstitutionsLocator.findManagedInstitution.yields(new Error('nope'))
+ @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) =>
+ should.exist(error)
+ expect(error).to.not.be.an.instanceof(Errors.NotFoundError)
+ done()
+
+ describe 'getUsers', ->
+ describe 'group', ->
+ it 'build view model for all users', (done) ->
+ @UserMembershipHandler.getUsers 'group', @subscription, (error, users) =>
+ expectedCallcount =
+ @subscription.member_ids.length +
+ @subscription.invited_emails.length +
+ @subscription.teamInvites.length
+ expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+ done()
+
+ describe 'group mamagers', ->
+ it 'build view model for all managers', (done) ->
+ @UserMembershipHandler.getUsers 'groupManagers', @subscription, (error, users) =>
+ expectedCallcount = @subscription.manager_ids.length
+ expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+ done()
+
+ describe 'institution', ->
+ it 'build view model for all managers', (done) ->
+ @UserMembershipHandler.getUsers 'institution', @institution, (error, users) =>
+ expectedCallcount = @institution.managerIds.length
+ expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount
+ done()
+
+ describe 'addUser', ->
+ beforeEach ->
+ @email = @newUser.email
+
+ describe 'group', ->
+ it 'fails', (done) ->
+ @UserMembershipHandler.addUser 'group', @subscription, @email, (error) =>
+ expect(error).to.exist
+ done()
+
+ describe 'institution', ->
+ it 'get user', (done) ->
+ @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+ assertCalledWith(@UserGetter.getUserByAnyEmail, @email)
+ done()
+
+ it 'handle user not found', (done) ->
+ @UserGetter.getUserByAnyEmail.yields(null, null)
+ @UserMembershipHandler.addUser 'institution', @institution, @email, (error) =>
+ expect(error).to.exist
+ expect(error).to.be.an.instanceof(Errors.NotFoundError)
+ done()
+
+ it 'add user to institution', (done) ->
+ @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+ assertCalledWith(@institution.update, { $addToSet: managerIds: @newUser._id })
+ done()
+
+ it 'return user view', (done) ->
+ @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) =>
+ user.should.equal @newUser
+ done()
+
+ describe 'removeUser', ->
+ describe 'institution', ->
+ it 'remove user from institution', (done) ->
+ @UserMembershipHandler.removeUser 'institution', @institution, @newUser._id, (error, user) =>
+ lastCall = @institution.update.lastCall
+ assertCalledWith(@institution.update, { $pull: managerIds: @newUser._id })
+ done()
diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee
new file mode 100644
index 0000000000..7dc7dbeff0
--- /dev/null
+++ b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee
@@ -0,0 +1,83 @@
+chai = require('chai')
+should = chai.should()
+expect = require('chai').expect
+sinon = require('sinon')
+assertCalledWith = sinon.assert.calledWith
+assertNotCalled = sinon.assert.notCalled
+mongojs = require('mongojs')
+ObjectId = mongojs.ObjectId
+modulePath = "../../../../app/js/Features/UserMembership/UserMembershipViewModel"
+SandboxedModule = require("sandboxed-module")
+
+describe 'UserMembershipViewModel', ->
+ beforeEach ->
+ @UserGetter =
+ getUserOrUserStubById: sinon.stub()
+ @UserMembershipViewModel = SandboxedModule.require modulePath, requires:
+ 'mongojs': mongojs
+ '../User/UserGetter': @UserGetter
+ @email = 'mock-email@bar.com'
+ @user = _id: 'mock-user-id', email: 'mock-email@baz.com', first_name: 'Name'
+ @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com'
+
+ describe 'build', ->
+ it 'build email', ->
+ viewModel = @UserMembershipViewModel.build(@email)
+ expect(viewModel).to.deep.equal
+ email: @email
+ invite: true
+ first_name: null
+ last_name: null
+ _id: null
+
+ it 'build user', ->
+ viewModel = @UserMembershipViewModel.build(@user)
+ expect(viewModel._id).to.equal @user._id
+ expect(viewModel.email).to.equal @user.email
+ expect(viewModel.invite).to.equal false
+
+ describe 'build async', ->
+ beforeEach ->
+ @UserMembershipViewModel.build = sinon.stub()
+
+ it 'build email', (done) ->
+ @UserMembershipViewModel.buildAsync @email, (error, viewModel) =>
+ assertCalledWith(@UserMembershipViewModel.build, @email)
+ done()
+
+ it 'build user', (done) ->
+ @UserMembershipViewModel.buildAsync @user, (error, viewModel) =>
+ assertCalledWith(@UserMembershipViewModel.build, @user)
+ done()
+
+ it 'build user id', (done) ->
+ @UserGetter.getUserOrUserStubById.yields(null, @user, false)
+ @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) =>
+ should.not.exist(error)
+ assertNotCalled(@UserMembershipViewModel.build)
+ expect(viewModel._id).to.equal @user._id
+ expect(viewModel.email).to.equal @user.email
+ expect(viewModel.first_name).to.equal @user.first_name
+ expect(viewModel.invite).to.equal false
+ should.exist(viewModel.email)
+ done()
+
+ it 'build user stub id', (done) ->
+ @UserGetter.getUserOrUserStubById.yields(null, @userStub, true)
+ @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) =>
+ should.not.exist(error)
+ assertNotCalled(@UserMembershipViewModel.build)
+ expect(viewModel._id).to.equal @userStub._id
+ expect(viewModel.email).to.equal @userStub.email
+ expect(viewModel.invite).to.equal true
+ done()
+
+ it 'build user id with error', (done) ->
+ @UserGetter.getUserOrUserStubById.yields(new Error('nope'))
+ userId = ObjectId()
+ @UserMembershipViewModel.buildAsync userId, (error, viewModel) =>
+ should.not.exist(error)
+ assertNotCalled(@UserMembershipViewModel.build)
+ expect(viewModel._id).to.equal userId.toString()
+ should.not.exist(viewModel.email)
+ done()