Răsfoiți Sursa

Merge branch 'master' into dcl-i1207

GitOrigin-RevId: c947041ca99860d4afb62ecfd28ba6fe1c717bfc
Douglas Lovell 7 ani în urmă
părinte
comite
aae0484458
44 a modificat fișierele cu 538 adăugiri și 148 ștergeri
  1. 3 0
      services/web/Makefile
  2. 13 13
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  3. 1 1
      services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee
  4. 5 2
      services/web/app/coffee/Features/Project/ProjectController.coffee
  5. 34 9
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  6. 16 3
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  7. 2 0
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  8. 9 7
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  9. 8 1
      services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee
  10. 13 1
      services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee
  11. 1 1
      services/web/app/coffee/Features/SudoMode/SudoModeMiddlewear.coffee
  12. 1 1
      services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee
  13. 1 1
      services/web/app/coffee/Features/User/UserPagesController.coffee
  14. 33 15
      services/web/app/coffee/Features/UserMembership/UserMembershipAuthorization.coffee
  15. 16 0
      services/web/app/coffee/Features/UserMembership/UserMembershipEntityConfigs.coffee
  16. 1 0
      services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee
  17. 36 20
      services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee
  18. 2 0
      services/web/app/coffee/infrastructure/ExpressLocals.coffee
  19. 34 0
      services/web/app/coffee/models/Publisher.coffee
  20. 1 0
      services/web/app/coffee/models/User.coffee
  21. 1 1
      services/web/app/views/project/editor/new-file-modal.pug
  22. 1 1
      services/web/app/views/project/list/modals.pug
  23. 3 0
      services/web/app/views/subscriptions/dashboard.pug
  24. 12 1
      services/web/app/views/subscriptions/dashboard/_managed_groups.pug
  25. 13 1
      services/web/app/views/subscriptions/dashboard/_managed_institutions.pug
  26. 60 0
      services/web/app/views/subscriptions/dashboard/_v1_subscription_status.pug
  27. 0 8
      services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug
  28. 1 1
      services/web/docker-compose.yml
  29. BIN
      services/web/public/img/other-brands/logo_ieee_white.png
  30. 2 1
      services/web/public/src/ide/file-tree/FileTreeManager.js
  31. 2 2
      services/web/public/stylesheets/components/tabs.less
  32. 8 0
      services/web/public/stylesheets/core/ol-ieee-variables.less
  33. 38 0
      services/web/public/stylesheets/ol-ieee-style.less
  34. 32 2
      services/web/test/acceptance/coffee/SubscriptionTests.coffee
  35. 3 0
      services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee
  36. 14 0
      services/web/test/acceptance/coffee/helpers/MockV1Api.coffee
  37. 8 8
      services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee
  38. 31 4
      services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee
  39. 1 0
      services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee
  40. 4 4
      services/web/test/unit/coffee/SudoMode/SudoModeMiddlewearTests.coffee
  41. 4 4
      services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee
  42. 4 4
      services/web/test/unit/coffee/User/UserPagesControllerTests.coffee
  43. 49 31
      services/web/test/unit/coffee/UserMembership/UserMembershipAuthorizationTests.coffee
  44. 17 0
      services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee

+ 3 - 0
services/web/Makefile

@@ -132,6 +132,9 @@ compile_full:
 	$(MAKE) compile_modules_full
 	$(MAKE) compile_modules_full
 	$(MAKE) compile # ide.js, main.js, share.js, and anything missed
 	$(MAKE) compile # ide.js, main.js, share.js, and anything missed
 
 
+compile_css_full:
+	$(MAKE) css_full
+
 compile_modules: $(MODULE_MAKEFILES)
 compile_modules: $(MODULE_MAKEFILES)
 	@set -e; \
 	@set -e; \
 	for dir in $(MODULE_DIRS); \
 	for dir in $(MODULE_DIRS); \

+ 13 - 13
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -203,7 +203,7 @@ module.exports = AuthenticationController =
 			return next()
 			return next()
 		else
 		else
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
 			logger.log url:req.url, "user trying to access endpoint not in global whitelist"
-			AuthenticationController._setRedirectInSession(req)
+			AuthenticationController.setRedirectInSession(req)
 			return res.redirect "/login"
 			return res.redirect "/login"
 
 
 	httpAuth: basicAuth (user, pass)->
 	httpAuth: basicAuth (user, pass)->
@@ -212,6 +212,16 @@ module.exports = AuthenticationController =
 			logger.err user:user, pass:pass, "invalid login details"
 			logger.err user:user, pass:pass, "invalid login details"
 		return isValid
 		return isValid
 
 
+	setRedirectInSession: (req, value) ->
+		if !value?
+			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}"
+		if (
+			req.session? &&
+				!/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) &&
+				!/^.*\.(png|jpeg|svg)$/.test(value)
+		)
+			req.session.postLoginRedirect = value
+
 	_redirectToLoginOrRegisterPage: (req, res)->
 	_redirectToLoginOrRegisterPage: (req, res)->
 		if (req.query.zipUrl? or req.query.project_name? or req.path == '/user/subscription/new')
 		if (req.query.zipUrl? or req.query.project_name? or req.path == '/user/subscription/new')
 			return AuthenticationController._redirectToRegisterPage(req, res)
 			return AuthenticationController._redirectToRegisterPage(req, res)
@@ -220,14 +230,14 @@ module.exports = AuthenticationController =
 
 
 	_redirectToLoginPage: (req, res) ->
 	_redirectToLoginPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to login page"
 		logger.log url: req.url, "user not logged in so redirecting to login page"
-		AuthenticationController._setRedirectInSession(req)
+		AuthenticationController.setRedirectInSession(req)
 		url = "/login?#{querystring.stringify(req.query)}"
 		url = "/login?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
 
 
 	_redirectToRegisterPage: (req, res) ->
 	_redirectToRegisterPage: (req, res) ->
 		logger.log url: req.url, "user not logged in so redirecting to register page"
 		logger.log url: req.url, "user not logged in so redirecting to register page"
-		AuthenticationController._setRedirectInSession(req)
+		AuthenticationController.setRedirectInSession(req)
 		url = "/register?#{querystring.stringify(req.query)}"
 		url = "/register?#{querystring.stringify(req.query)}"
 		res.redirect url
 		res.redirect url
 		Metrics.inc "security.login-redirect"
 		Metrics.inc "security.login-redirect"
@@ -245,16 +255,6 @@ module.exports = AuthenticationController =
 		Metrics.inc "user.login.failed"
 		Metrics.inc "user.login.failed"
 		callback()
 		callback()
 
 
-	_setRedirectInSession: (req, value) ->
-		if !value?
-			value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else "#{req.path}"
-		if (
-			req.session? &&
-			!/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) &&
-			!/^.*\.(png|jpeg|svg)$/.test(value)
-		)
-			req.session.postLoginRedirect = value
-
 	_getRedirectFromSession: (req) ->
 	_getRedirectFromSession: (req) ->
 		return req?.session?.postLoginRedirect || null
 		return req?.session?.postLoginRedirect || null
 
 

+ 1 - 1
services/web/app/coffee/Features/Authorization/AuthorizationMiddlewear.coffee

@@ -117,5 +117,5 @@ module.exports = AuthorizationMiddlewear =
 			logger.log {from: from}, "redirecting to login"
 			logger.log {from: from}, "redirecting to login"
 			redirect_to = "/login"
 			redirect_to = "/login"
 			if from?
 			if from?
-				AuthenticationController._setRedirectInSession(req, from)
+				AuthenticationController.setRedirectInSession(req, from)
 			res.redirect redirect_to
 			res.redirect redirect_to

+ 5 - 2
services/web/app/coffee/Features/Project/ProjectController.coffee

@@ -270,7 +270,7 @@ module.exports = ProjectController =
 			project: (cb)->
 			project: (cb)->
 				ProjectGetter.getProject(
 				ProjectGetter.getProject(
 					project_id,
 					project_id,
-					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, brandVariationId: 1, 'overleaf.history.display': 1 },
+					{ name: 1, lastUpdated: 1, track_changes: 1, owner_ref: 1, brandVariationId: 1, overleaf: 1 },
 					cb
 					cb
 				)
 				)
 			user: (cb)->
 			user: (cb)->
@@ -323,6 +323,9 @@ module.exports = ProjectController =
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
 				if subscription? and subscription.freeTrial? and subscription.freeTrial.expiresAt?
 					allowedFreeTrial = !!subscription.freeTrial.allowed || true
 					allowedFreeTrial = !!subscription.freeTrial.allowed || true
 
 
+				showGitBridge =
+					user.betaProgram && !project.overleaf?.id? # don't support v1 projects yet
+
 				logger.log project_id:project_id, "rendering editor page"
 				logger.log project_id:project_id, "rendering editor page"
 				res.render 'project/editor',
 				res.render 'project/editor',
 					title:  project.name
 					title:  project.name
@@ -370,7 +373,7 @@ module.exports = ProjectController =
 					brandVariation: brandVariation
 					brandVariation: brandVariation
 					allowedImageNames: Settings.allowedImageNames || []
 					allowedImageNames: Settings.allowedImageNames || []
 					gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl
 					gitBridgePublicBaseUrl: Settings.gitBridgePublicBaseUrl
-					showGitBridge: req.query?.gitbridge == 'true' || user.isAdmin
+					showGitBridge: showGitBridge
 				timer.done()
 				timer.done()
 
 
 	_buildProjectList: (allProjects, v1Projects = [])->
 	_buildProjectList: (allProjects, v1Projects = [])->

+ 34 - 9
services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee

@@ -74,13 +74,7 @@ module.exports = ProjectDetailsHandler =
 		if arguments.length is 3 && typeof suffixes is 'function' # make suffixes an optional argument
 		if arguments.length is 3 && typeof suffixes is 'function' # make suffixes an optional argument
 			callback = suffixes
 			callback = suffixes
 			suffixes = []
 			suffixes = []
-		timestamp = new Date().toISOString().replace(/T(\d+):(\d+):(\d+)\..*/,' $1$2$3') # strip out unwanted characters
-		ProjectDetailsHandler.ensureProjectNameIsUnique user_id, name, suffixes.concat(" (#{timestamp})"), callback
-
-	_addSuffixToProjectName: (name, suffix = '') ->
-		# append the suffix and truncate the project title if needed
-		truncatedLength = ProjectDetailsHandler.MAX_PROJECT_NAME_LENGTH - suffix.length
-		return name.substr(0, truncatedLength) + suffix
+		ProjectDetailsHandler.ensureProjectNameIsUnique user_id, name, suffixes, callback
 
 
 	# FIXME: we should put a lock around this to make it completely safe, but we would need to do that at
 	# FIXME: we should put a lock around this to make it completely safe, but we would need to do that at
 	# the point of project creation, rather than just checking the name at the start of the import.
 	# the point of project creation, rather than just checking the name at the start of the import.
@@ -106,8 +100,12 @@ module.exports = ProjectDetailsHandler =
 				candidateName = ProjectDetailsHandler._addSuffixToProjectName(name, suffix)
 				candidateName = ProjectDetailsHandler._addSuffixToProjectName(name, suffix)
 				if isUnique(candidateName)
 				if isUnique(candidateName)
 					return callback(null, candidateName, true)
 					return callback(null, candidateName, true)
-			# we couldn't make the name unique, something is wrong
-			return callback new Errors.InvalidNameError("Project name could not be made unique")
+			# if there are no (more) suffixes, use a numeric one
+			uniqueName = ProjectDetailsHandler._addNumericSuffixToProjectName(name, allProjectNames)
+			if uniqueName?
+				callback(null, uniqueName, true)
+			else
+				callback(new Error("Failed to generate a unique name for file: #{name}"))
 	
 	
 	fixProjectName: (name) ->
 	fixProjectName: (name) ->
 		if name == "" || !name
 		if name == "" || !name
@@ -156,3 +154,30 @@ module.exports = ProjectDetailsHandler =
 				Project.update {_id: project_id}, {$set: {tokens: tokens}}, (err) ->
 				Project.update {_id: project_id}, {$set: {tokens: tokens}}, (err) ->
 					return callback(err) if err?
 					return callback(err) if err?
 					callback(null, tokens)
 					callback(null, tokens)
+
+	_addSuffixToProjectName: (name, suffix = '') ->
+		# append the suffix and truncate the project title if needed
+		truncatedLength = ProjectDetailsHandler.MAX_PROJECT_NAME_LENGTH - suffix.length
+		return name.substr(0, truncatedLength) + suffix
+
+	_addNumericSuffixToProjectName: (name, allProjectNames) ->
+		NUMERIC_SUFFIX_MATCH = / \((\d+)\)$/
+		suffixedName = (basename, number) ->
+			suffix = " (#{number})"
+			return basename.substr(0, ProjectDetailsHandler.MAX_PROJECT_NAME_LENGTH - suffix.length) + suffix
+
+		match = name.match(NUMERIC_SUFFIX_MATCH)
+		basename = name
+		n = 1
+		last = allProjectNames.size + n
+
+		if match?
+			basename = name.replace(NUMERIC_SUFFIX_MATCH, '')
+			n = parseInt(match[1])
+
+		while n <= last
+			candidate = suffixedName(basename, n)
+			return candidate unless allProjectNames.has(candidate)
+			n += 1
+
+		return null

+ 16 - 3
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -12,6 +12,7 @@ UserGetter = require "../User/UserGetter"
 FeaturesUpdater = require './FeaturesUpdater'
 FeaturesUpdater = require './FeaturesUpdater'
 planFeatures = require './planFeatures'
 planFeatures = require './planFeatures'
 GroupPlansData = require './GroupPlansData'
 GroupPlansData = require './GroupPlansData'
+V1SubscriptionManager = require "./V1SubscriptionManager"
 
 
 module.exports = SubscriptionController =
 module.exports = SubscriptionController =
 
 
@@ -97,7 +98,8 @@ module.exports = SubscriptionController =
 				managedGroupSubscriptions,
 				managedGroupSubscriptions,
 				confirmedMemberInstitutions,
 				confirmedMemberInstitutions,
 				managedInstitutions,
 				managedInstitutions,
-				v1Subscriptions
+				v1Subscriptions,
+				v1SubscriptionStatus
 			} = results
 			} = results
 			logger.log {
 			logger.log {
 				user,
 				user,
@@ -106,7 +108,8 @@ module.exports = SubscriptionController =
 				managedGroupSubscriptions,
 				managedGroupSubscriptions,
 				confirmedMemberInstitutions,
 				confirmedMemberInstitutions,
 				managedInstitutions,
 				managedInstitutions,
-				v1Subscriptions
+				v1Subscriptions,
+				v1SubscriptionStatus
 			}, "showing subscription dashboard"
 			}, "showing subscription dashboard"
 			plans = SubscriptionViewModelBuilder.buildViewModel()
 			plans = SubscriptionViewModelBuilder.buildViewModel()
 			data = {
 			data = {
@@ -118,7 +121,8 @@ module.exports = SubscriptionController =
 				managedGroupSubscriptions,
 				managedGroupSubscriptions,
 				confirmedMemberInstitutions,
 				confirmedMemberInstitutions,
 				managedInstitutions,
 				managedInstitutions,
-				v1Subscriptions
+				v1Subscriptions,
+				v1SubscriptionStatus
 			}
 			}
 			res.render "subscriptions/dashboard", data
 			res.render "subscriptions/dashboard", data
 
 
@@ -158,6 +162,15 @@ module.exports = SubscriptionController =
 				return next(err)
 				return next(err)
 			res.redirect "/user/subscription"
 			res.redirect "/user/subscription"
 
 
+	cancelV1Subscription: (req, res, next) ->
+		user_id = AuthenticationController.getLoggedInUserId(req)
+		logger.log {user_id}, "canceling v1 subscription"
+		V1SubscriptionManager.cancelV1Subscription user_id, (err)->
+			if err?
+				logger.err err:err, user_id:user_id, "something went wrong canceling v1 subscription"
+				return next(err)
+			res.redirect "/user/subscription"
+
 	updateSubscription: (req, res, next)->
 	updateSubscription: (req, res, next)->
 		_origin = req?.query?.origin || null
 		_origin = req?.query?.origin || null
 		user = AuthenticationController.getSessionUser(req)
 		user = AuthenticationController.getSessionUser(req)

+ 2 - 0
services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee

@@ -40,6 +40,8 @@ module.exports =
 		webRouter.post '/user/subscription/cancel',     AuthenticationController.requireLogin(), SubscriptionController.cancelSubscription
 		webRouter.post '/user/subscription/cancel',     AuthenticationController.requireLogin(), SubscriptionController.cancelSubscription
 		webRouter.post '/user/subscription/reactivate', AuthenticationController.requireLogin(), SubscriptionController.reactivateSubscription
 		webRouter.post '/user/subscription/reactivate', AuthenticationController.requireLogin(), SubscriptionController.reactivateSubscription
 
 
+		webRouter.post '/user/subscription/v1/cancel', AuthenticationController.requireLogin(), SubscriptionController.cancelV1Subscription
+
 		webRouter.put '/user/subscription/extend', AuthenticationController.requireLogin(), SubscriptionController.extendTrial
 		webRouter.put '/user/subscription/extend', AuthenticationController.requireLogin(), SubscriptionController.extendTrial
 
 
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage
 		webRouter.get "/user/subscription/upgrade-annual",  AuthenticationController.requireLogin(), SubscriptionController.renderUpgradeToAnnualPlanPage

+ 9 - 7
services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee

@@ -29,13 +29,7 @@ module.exports = SubscriptionUpdater =
 		@addUsersToGroup(subscriptionId, [userId], callback)
 		@addUsersToGroup(subscriptionId, [userId], callback)
 
 
 	addUsersToGroup: (subscriptionId, memberIds, callback)->
 	addUsersToGroup: (subscriptionId, memberIds, callback)->
-		logger.log subscriptionId: subscriptionId, memberIds: memberIds, "adding members into mongo subscription"
-		searchOps =
-			_id: subscriptionId
-		insertOperation =
-			{ $addToSet: { member_ids: { $each: memberIds } } }
-
-		Subscription.findAndModify searchOps, insertOperation, (err, subscription) ->
+		@addUsersToGroupWithoutFeaturesRefresh subscriptionId, memberIds, (err) ->
 			return callback(err) if err?
 			return callback(err) if err?
 
 
 			# Only apply features updates to users, not user stubs
 			# Only apply features updates to users, not user stubs
@@ -45,6 +39,14 @@ module.exports = SubscriptionUpdater =
 				userIds = users.map (u) -> u._id.toString()
 				userIds = users.map (u) -> u._id.toString()
 				async.map userIds, FeaturesUpdater.refreshFeatures, callback
 				async.map userIds, FeaturesUpdater.refreshFeatures, callback
 
 
+	addUsersToGroupWithoutFeaturesRefresh: (subscriptionId, memberIds, callback)->
+		logger.log subscriptionId: subscriptionId, memberIds: memberIds, "adding members into mongo subscription"
+		searchOps =
+			_id: subscriptionId
+		insertOperation =
+			{ $addToSet: { member_ids: { $each: memberIds } } }
+
+		Subscription.findAndModify searchOps, insertOperation, callback
 
 
 	removeUserFromGroup: (subscriptionId, user_id, callback)->
 	removeUserFromGroup: (subscriptionId, user_id, callback)->
 		searchOps =
 		searchOps =

+ 8 - 1
services/web/app/coffee/Features/Subscription/SubscriptionViewModelBuilder.coffee

@@ -51,6 +51,10 @@ module.exports =
 					return cb(error) if error?
 					return cb(error) if error?
 					# Only return one argument to async.auto, otherwise it returns an array
 					# Only return one argument to async.auto, otherwise it returns an array
 					cb(null, subscriptions)
 					cb(null, subscriptions)
+			v1SubscriptionStatus: (cb) ->
+				V1SubscriptionManager.getSubscriptionStatusFromV1 user._id, (error, status, v1Id) ->
+					return cb(error) if error?
+					cb(null, status)
 		}, (err, results) ->
 		}, (err, results) ->
 			return callback(err) if err?
 			return callback(err) if err?
 			{
 			{
@@ -60,6 +64,7 @@ module.exports =
 				confirmedMemberInstitutions,
 				confirmedMemberInstitutions,
 				managedInstitutions,
 				managedInstitutions,
 				v1Subscriptions,
 				v1Subscriptions,
+				v1SubscriptionStatus,
 				recurlySubscription,
 				recurlySubscription,
 				plan
 				plan
 			} = results
 			} = results
@@ -68,6 +73,7 @@ module.exports =
 			confirmedMemberInstitutions ?= []
 			confirmedMemberInstitutions ?= []
 			managedInstitutions ?= []
 			managedInstitutions ?= []
 			v1Subscriptions ?= {}
 			v1Subscriptions ?= {}
+			v1SubscriptionStatus ?= {}
 
 
 
 
 			if personalSubscription?.toObject?
 			if personalSubscription?.toObject?
@@ -97,7 +103,8 @@ module.exports =
 				memberGroupSubscriptions,
 				memberGroupSubscriptions,
 				confirmedMemberInstitutions,
 				confirmedMemberInstitutions,
 				managedInstitutions,
 				managedInstitutions,
-				v1Subscriptions
+				v1Subscriptions,
+				v1SubscriptionStatus
 			}
 			}
 
 
 	buildViewModel : ->
 	buildViewModel : ->

+ 13 - 1
services/web/app/coffee/Features/Subscription/V1SubscriptionManager.coffee

@@ -39,6 +39,18 @@ module.exports = V1SubscriptionManager =
 			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/subscriptions"
 			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/subscriptions"
 		}, callback
 		}, callback
 
 
+	getSubscriptionStatusFromV1: (userId, callback=(err, status) ->) ->
+		V1SubscriptionManager._v1Request userId, {
+			method: 'GET',
+			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/subscription_status"
+		}, callback
+
+	cancelV1Subscription: (userId, callback=(err)->) ->
+		V1SubscriptionManager._v1Request userId, {
+			method: 'DELETE',
+			url: (v1Id) -> "/api/v1/sharelatex/users/#{v1Id}/subscription"
+		}, callback
+
 	v1IdForUser: (userId, callback=(err, v1Id) ->) ->
 	v1IdForUser: (userId, callback=(err, v1Id) ->) ->
 		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
 		UserGetter.getUser userId, {'overleaf.id': 1}, (err, user) ->
 			return callback(err) if err?
 			return callback(err) if err?
@@ -76,7 +88,7 @@ module.exports = V1SubscriptionManager =
 					pass: settings.apis.v1.pass
 					pass: settings.apis.v1.pass
 					sendImmediately: true
 					sendImmediately: true
 				json: true,
 				json: true,
-				timeout: 5 * 1000
+				timeout: 15 * 1000
 			}, (error, response, body) ->
 			}, (error, response, body) ->
 				if error?
 				if error?
 					# Specially handle no connection err, so warning can be shown
 					# Specially handle no connection err, so warning can be shown

+ 1 - 1
services/web/app/coffee/Features/SudoMode/SudoModeMiddlewear.coffee

@@ -21,5 +21,5 @@ module.exports = SudoModeMiddlewear =
 				return next()
 				return next()
 			else
 			else
 				logger.log {userId}, "[SudoMode] sudo mode not active, redirecting"
 				logger.log {userId}, "[SudoMode] sudo mode not active, redirecting"
-				AuthenticationController._setRedirectInSession(req)
+				AuthenticationController.setRedirectInSession(req)
 				return res.redirect('/confirm-password')
 				return res.redirect('/confirm-password')

+ 1 - 1
services/web/app/coffee/Features/TokenAccess/TokenAccessController.coffee

@@ -60,7 +60,7 @@ module.exports = TokenAccessController =
 					else
 					else
 						logger.log {token, projectId: project._id},
 						logger.log {token, projectId: project._id},
 							"[TokenAccess] deny anonymous read-and-write token access"
 							"[TokenAccess] deny anonymous read-and-write token access"
-						AuthenticationController._setRedirectInSession(req)
+						AuthenticationController.setRedirectInSession(req)
 						return res.redirect('/restricted')
 						return res.redirect('/restricted')
 				if project.owner_ref.toString() == userId
 				if project.owner_ref.toString() == userId
 					logger.log {userId, projectId: project._id},
 					logger.log {userId, projectId: project._id},

+ 1 - 1
services/web/app/coffee/Features/User/UserPagesController.coffee

@@ -51,7 +51,7 @@ module.exports =
 		# such as being sent from the editor to /login, then set the redirect explicitly
 		# such as being sent from the editor to /login, then set the redirect explicitly
 		if req.query.redir? and !AuthenticationController._getRedirectFromSession(req)?
 		if req.query.redir? and !AuthenticationController._getRedirectFromSession(req)?
 			logger.log {redir: req.query.redir}, "setting explicit redirect from login page"
 			logger.log {redir: req.query.redir}, "setting explicit redirect from login page"
-			AuthenticationController._setRedirectInSession(req, req.query.redir)
+			AuthenticationController.setRedirectInSession(req, req.query.redir)
 		res.render 'user/login',
 		res.render 'user/login',
 			title: 'login',
 			title: 'login',
 			email: req.query.email
 			email: req.query.email

+ 33 - 15
services/web/app/coffee/Features/UserMembership/UserMembershipAuthorization.coffee

@@ -6,21 +6,39 @@ Errors = require('../Errors/Errors')
 logger = require("logger-sharelatex")
 logger = require("logger-sharelatex")
 
 
 module.exports =
 module.exports =
-	requireEntityAccess: (entityName, entityIdOverride = null) ->
-		(req, res, next) ->
-			loggedInUser = AuthenticationController.getSessionUser(req)
-			unless loggedInUser
-				return AuthorizationMiddlewear.redirectToRestricted req, res, next
-
-			entityId = entityIdOverride or req.params.id
-			getEntity entityName, entityId, loggedInUser, (error, entity, entityConfig) ->
-				return next(error) if error?
-				unless entity?
-					return AuthorizationMiddlewear.redirectToRestricted(req, res, next)
-
-				req.entity = entity
-				req.entityConfig = entityConfig
-				next()
+	requireTeamAccess: (req, res, next) ->
+		requireAccessToEntity('team', req.params.id, req, res, next)
+
+	requireGroupAccess: (req, res, next) ->
+		requireAccessToEntity('group', req.params.id, req, res, next)
+
+	requireGroupManagersAccess: (req, res, next) ->
+		requireAccessToEntity('groupManagers', req.params.id, req, res, next)
+
+	requireInstitutionAccess: (req, res, next) ->
+		requireAccessToEntity('institution', req.params.id, req, res, next)
+
+	requirePublisherAccess: (req, res, next) ->
+		requireAccessToEntity('publisher', req.params.id, req, res, next)
+
+	requireGraphAccess: (req, res, next) ->
+		requireAccessToEntity(
+			req.query.resource_type, req.query.resource_id, req, res, next
+		)
+
+requireAccessToEntity = (entityName, entityId, req, res, next) ->
+	loggedInUser = AuthenticationController.getSessionUser(req)
+	unless loggedInUser
+		return AuthorizationMiddlewear.redirectToRestricted req, res, next
+
+	getEntity entityName, entityId, loggedInUser, (error, entity, entityConfig) ->
+		return next(error) if error?
+		unless entity?
+			return AuthorizationMiddlewear.redirectToRestricted(req, res, next)
+
+		req.entity = entity
+		req.entityConfig = entityConfig
+		next()
 
 
 getEntity = (entityName, entityId, userId, callback = (error, entity, entityConfig)->) ->
 getEntity = (entityName, entityId, userId, callback = (error, entity, entityConfig)->) ->
 	entityConfig = EntityConfigs[entityName]
 	entityConfig = EntityConfigs[entityName]

+ 16 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipEntityConfigs.coffee

@@ -62,3 +62,19 @@ module.exports =
 		pathsFor: (id) ->
 		pathsFor: (id) ->
 			addMember: "/manage/institutions/#{id}/managers"
 			addMember: "/manage/institutions/#{id}/managers"
 			removeMember: "/manage/institutions/#{id}/managers"
 			removeMember: "/manage/institutions/#{id}/managers"
+
+	publisher:
+		modelName: 'Publisher'
+		fields:
+			primaryKey: 'slug'
+			read: ['managerIds']
+			write: 'managerIds'
+			access: 'managerIds'
+			name: 'name'
+		translations:
+			title: 'publisher_account'
+			subtitle: 'managers_management'
+			remove: 'remove_manager'
+		pathsFor: (id) ->
+			addMember: "/manage/publishers/#{id}/managers"
+			removeMember: "/manage/publishers/#{id}/managers"

+ 1 - 0
services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee

@@ -4,6 +4,7 @@ Errors = require('../Errors/Errors')
 EntityModels =
 EntityModels =
 	Institution: require('../../models/Institution').Institution
 	Institution: require('../../models/Institution').Institution
 	Subscription: require('../../models/Subscription').Subscription
 	Subscription: require('../../models/Subscription').Subscription
+	Publisher: require('../../models/Publisher').Publisher
 UserMembershipViewModel = require('./UserMembershipViewModel')
 UserMembershipViewModel = require('./UserMembershipViewModel')
 UserGetter = require('../User/UserGetter')
 UserGetter = require('../User/UserGetter')
 logger = require('logger-sharelatex')
 logger = require('logger-sharelatex')

+ 36 - 20
services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee

@@ -5,36 +5,52 @@ TeamInvitesController = require '../Subscription/TeamInvitesController'
 
 
 module.exports =
 module.exports =
 	apply: (webRouter) ->
 	apply: (webRouter) ->
+		# group members routes
 		webRouter.get '/manage/groups/:id/members',
 		webRouter.get '/manage/groups/:id/members',
-			UserMembershipAuthorization.requireEntityAccess('group'),
+			UserMembershipAuthorization.requireGroupAccess,
 			UserMembershipController.index
 			UserMembershipController.index
 		webRouter.post '/manage/groups/:id/invites',
 		webRouter.post '/manage/groups/:id/invites',
-			UserMembershipAuthorization.requireEntityAccess('group'),
+			UserMembershipAuthorization.requireGroupAccess,
 			TeamInvitesController.createInvite
 			TeamInvitesController.createInvite
 		webRouter.delete '/manage/groups/:id/user/:user_id',
 		webRouter.delete '/manage/groups/:id/user/:user_id',
-			UserMembershipAuthorization.requireEntityAccess('group'),
+			UserMembershipAuthorization.requireGroupAccess,
 			SubscriptionGroupController.removeUserFromGroup
 			SubscriptionGroupController.removeUserFromGroup
 		webRouter.delete '/manage/groups/:id/invites/:email',
 		webRouter.delete '/manage/groups/:id/invites/:email',
-			UserMembershipAuthorization.requireEntityAccess('group'),
+			UserMembershipAuthorization.requireGroupAccess,
 			TeamInvitesController.revokeInvite
 			TeamInvitesController.revokeInvite
 		webRouter.get '/manage/groups/:id/members/export',
 		webRouter.get '/manage/groups/:id/members/export',
-			UserMembershipAuthorization.requireEntityAccess('group'),
+			UserMembershipAuthorization.requireGroupAccess,
 			UserMembershipController.exportCsv
 			UserMembershipController.exportCsv
 
 
+		# group managers routes
+		webRouter.get "/manage/groups/:id/managers",
+			UserMembershipAuthorization.requireGroupManagersAccess,
+			UserMembershipController.index
+		webRouter.post "/manage/groups/:id/managers",
+			UserMembershipAuthorization.requireGroupManagersAccess,
+			UserMembershipController.add
+		webRouter.delete "/manage/groups/:id/managers/:userId",
+			UserMembershipAuthorization.requireGroupManagersAccess,
+			UserMembershipController.remove
 
 
-		regularEntitites =
-			groups: 'groupManagers'
-			institutions: 'institution'
-		for pathName, entityName of regularEntitites
-			do (pathName, entityName) ->
-				webRouter.get "/manage/#{pathName}/:id/managers",
-					UserMembershipAuthorization.requireEntityAccess(entityName),
-					UserMembershipController.index
-
-				webRouter.post "/manage/#{pathName}/:id/managers",
-					UserMembershipAuthorization.requireEntityAccess(entityName),
-					UserMembershipController.add
+		# institution members routes
+		webRouter.get "/manage/institutions/:id/managers",
+			UserMembershipAuthorization.requireInstitutionAccess,
+			UserMembershipController.index
+		webRouter.post "/manage/institutions/:id/managers",
+			UserMembershipAuthorization.requireInstitutionAccess,
+			UserMembershipController.add
+		webRouter.delete "/manage/institutions/:id/managers/:userId",
+			UserMembershipAuthorization.requireInstitutionAccess,
+			UserMembershipController.remove
 
 
-				webRouter.delete "/manage/#{pathName}/:id/managers/:userId",
-					UserMembershipAuthorization.requireEntityAccess(entityName),
-					UserMembershipController.remove
+		# publisher members routes
+		webRouter.get "/manage/publishers/:id/managers",
+			UserMembershipAuthorization.requirePublisherAccess,
+			UserMembershipController.index
+		webRouter.post "/manage/publishers/:id/managers",
+			UserMembershipAuthorization.requirePublisherAccess,
+			UserMembershipController.add
+		webRouter.delete "/manage/publishers/:id/managers/:userId",
+			UserMembershipAuthorization.requirePublisherAccess,
+			UserMembershipController.remove

+ 2 - 0
services/web/app/coffee/infrastructure/ExpressLocals.coffee

@@ -16,6 +16,7 @@ hashedFiles = {}
 Path = require 'path'
 Path = require 'path'
 Features = require "./Features"
 Features = require "./Features"
 Modules = require "./Modules"
 Modules = require "./Modules"
+moment = require 'moment'
 
 
 jsPath =
 jsPath =
 	if Settings.useMinifiedJs
 	if Settings.useMinifiedJs
@@ -126,6 +127,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)->
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
 		res.locals.fullJsPath = Url.resolve(staticFilesBase, jsPath)
 		res.locals.lib = PackageVersions.lib
 		res.locals.lib = PackageVersions.lib
 
 
+		res.locals.moment = moment
 
 
 		res.locals.buildJsPath = (jsFile, opts = {})->
 		res.locals.buildJsPath = (jsFile, opts = {})->
 			path = Path.join(jsPath, jsFile)
 			path = Path.join(jsPath, jsFile)

+ 34 - 0
services/web/app/coffee/models/Publisher.coffee

@@ -0,0 +1,34 @@
+mongoose = require 'mongoose'
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+settings = require 'settings-sharelatex'
+request = require 'request'
+
+PublisherSchema = new Schema
+	slug: { type: String, required: true }
+	managerIds: [ type:ObjectId, ref:'User' ]
+
+# fetch publisher's (brand on v1) data from v1 API. Errors are ignored
+PublisherSchema.method 'fetchV1Data', (callback = (error, publisher)->) ->
+	request {
+		baseUrl: settings.apis.v1.url
+		url: "/api/v2/brands/#{this.slug}"
+		method: 'GET'
+		auth:
+			user: settings.apis.v1.user
+			pass: settings.apis.v1.pass
+			sendImmediately: true
+	}, (error, response, body) =>
+		try parsedBody = JSON.parse(body) catch e
+		this.name = parsedBody?.name
+		this.partner = parsedBody?.partner
+		callback(null, this)
+
+conn = mongoose.createConnection(settings.mongo.url, {
+	server: {poolSize: settings.mongo.poolSize || 10},
+	config: {autoIndex: false}
+})
+
+Publisher = conn.model 'Publisher', PublisherSchema
+exports.Publisher = Publisher
+exports.PublisherSchema = PublisherSchema

+ 1 - 0
services/web/app/coffee/models/User.coffee

@@ -50,6 +50,7 @@ UserSchema = new Schema
 		references:       { type:Boolean, default: Settings.defaultFeatures.references }
 		references:       { type:Boolean, default: Settings.defaultFeatures.references }
 		trackChanges:     { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 		trackChanges:     { type:Boolean, default: Settings.defaultFeatures.trackChanges }
 		mendeley:         { type:Boolean, default: Settings.defaultFeatures.mendeley }
 		mendeley:         { type:Boolean, default: Settings.defaultFeatures.mendeley }
+		zotero:           { type:Boolean, default: Settings.defaultFeatures.zotero }
 		referencesSearch: { type:Boolean, default: Settings.defaultFeatures.referencesSearch }
 		referencesSearch: { type:Boolean, default: Settings.defaultFeatures.referencesSearch }
 	}
 	}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}
 	referal_id : {type:String, default:() -> uuid.v4().split("-")[0]}

+ 1 - 1
services/web/app/views/project/editor/new-file-modal.pug

@@ -162,7 +162,7 @@ script(type='text/ng-template', id='newFileModalTemplate')
 								on-enter="create()",
 								on-enter="create()",
 								name="url"
 								name="url"
 							)
 							)
-							.row-spaced.small
+							.row-spaced-small
 								label(for="name") File name in this project
 								label(for="name") File name in this project
 								input.form-control(
 								input.form-control(
 									type="text",
 									type="text",

+ 1 - 1
services/web/app/views/project/list/modals.pug

@@ -351,7 +351,7 @@ script(type="text/ng-template", id="v1ImportModalTemplate")
 						| <strong>Direct git access to your projects</strong> is not yet available, but you can migrate your project to the Overleaf v2 GitHub integration
 						| <strong>Direct git access to your projects</strong> is not yet available, but you can migrate your project to the Overleaf v2 GitHub integration
 						|
 						|
 						a(href='https://www.overleaf.com/help/343-working-offline-in-overleaf-v2', target='_blank') Read More.
 						a(href='https://www.overleaf.com/help/343-working-offline-in-overleaf-v2', target='_blank') Read More.
-					li There are <strong>no Zotero and CiteULike</strong> integrations yet
+					li There is no <strong>CiteULike</strong> integration
 					li Some <strong>Journals and Services in the Submit menu</strong> don't support direct submissions yet
 					li Some <strong>Journals and Services in the Submit menu</strong> don't support direct submissions yet
 		.v1-import-cta
 		.v1-import-cta
 			p
 			p

+ 3 - 0
services/web/app/views/subscriptions/dashboard.pug

@@ -35,6 +35,9 @@ block content
 						-if (settings.overleaf && v1Subscriptions)
 						-if (settings.overleaf && v1Subscriptions)
 							include ./dashboard/_v1_subscriptions
 							include ./dashboard/_v1_subscriptions
 
 
+						-if (v1SubscriptionStatus)
+							include ./dashboard/_v1_subscription_status
+
 						-if (!hasAnySubscription)
 						-if (!hasAnySubscription)
 							p You're on the #{settings.appName} Free plan.
 							p You're on the #{settings.appName} Free plan.
 							|
 							|

+ 12 - 1
services/web/app/views/subscriptions/dashboard/_managed_groups.pug

@@ -5,9 +5,20 @@ each managedGroupSubscription in managedGroupSubscriptions
 		+teamName(managedGroupSubscription)
 		+teamName(managedGroupSubscription)
 	p
 	p
 		a.btn.btn-primary(href="/manage/groups/" + managedGroupSubscription._id + "/members")
 		a.btn.btn-primary(href="/manage/groups/" + managedGroupSubscription._id + "/members")
+			i.fa.fa-fw.fa-users
+			| &nbsp;
 			| Manage members
 			| Manage members
 		| &nbsp;
 		| &nbsp;
-		a.btn.btn-primary(href="/manage/groups/" + managedGroupSubscription._id + "/managers")
+	p
+		a(href="/manage/groups/" + managedGroupSubscription._id + "/managers")
+			i.fa.fa-fw.fa-users
+			| &nbsp;
 			| Manage group managers
 			| Manage group managers
+		| &nbsp;
+	p
+		a(href="/metrics/groups/" + managedGroupSubscription._id)
+			i.fa.fa-fw.fa-line-chart
+			| &nbsp;
+			| View metrics
 
 
 	hr
 	hr

+ 13 - 1
services/web/app/views/subscriptions/dashboard/_managed_institutions.pug

@@ -4,6 +4,18 @@ each institution in managedInstitutions
 		|
 		|
 		strong= institution.name
 		strong= institution.name
 	p
 	p
-		a.btn.btn-primary(href="/manage/institutions/" + institution.v1Id + "/managers")
+		a.btn.btn-primary(href="/metrics/institutions/" + institution.v1Id)
+			i.fa.fa-fw.fa-line-chart
+			| &nbsp;
+			| View metrics
+	p
+		a(href="/institutions/" + institution.v1Id + "/hub")
+			i.fa.fa-fw.fa-user-circle
+			| &nbsp;
+			| View hub
+	p
+		a(href="/manage/institutions/" + institution.v1Id + "/managers")
+			i.fa.fa-fw.fa-users
+			| &nbsp;
 			| Manage institution managers
 			| Manage institution managers
 	hr
 	hr

+ 60 - 0
services/web/app/views/subscriptions/dashboard/_v1_subscription_status.pug

@@ -0,0 +1,60 @@
+- if (v1SubscriptionStatus['team'])
+  p
+    | You have a legacy group licence from Overleaf v1.
+  - if (v1SubscriptionStatus['team']['will_end_at'])
+    p
+      | Your current group licence ends on
+      |
+      strong= moment(v1SubscriptionStatus['team']['will_end_at']).format('Do MMM YY')
+      |
+      | and will
+      |
+      - if (v1SubscriptionStatus['team']['will_renew'])
+        | be automatically renewed.
+      - else 
+        | not be automatically renewed.
+  - if (v1SubscriptionStatus['can_cancel_team'])
+    p
+      form(method="POST", action="/user/subscription/v1/cancel")
+        input(type="hidden", name="_csrf", value=csrfToken)
+        button().btn.btn-danger Stop automatic renewal
+  - else
+    p
+      | Please
+      |
+      a(href="/contact") contact support
+      |
+      | to make changes to your plan
+  hr
+
+- if (v1SubscriptionStatus['product'])
+  p  
+    | You have a legacy Overleaf v1
+    |
+    strong= v1SubscriptionStatus['product']['display_name']
+    |
+    | plan.
+  p
+    | Your plan ends on
+    |
+    strong= moment(v1SubscriptionStatus['product']['will_end_at']).format('Do MMM YY')
+    |
+    | and will
+    |
+    - if (v1SubscriptionStatus['product']['will_renew'])
+      | be automatically renewed.
+    - else 
+      | not be automatically renewed.
+  - if (v1SubscriptionStatus['can_cancel'])
+    p
+      form(method="POST", action="/user/subscription/v1/cancel")
+        input(type="hidden", name="_csrf", value=csrfToken)
+        button().btn.btn-danger Stop automatic renewal
+  - else
+    p
+      | Please
+      |
+      a(href="/contact") contact support
+      |
+      | to make changes to your plan
+  hr

+ 0 - 8
services/web/app/views/subscriptions/dashboard/_v1_subscriptions.pug

@@ -1,11 +1,3 @@
-- if (v1Subscriptions.has_subscription)
-	-hasAnySubscription = true
-	p
-		| You are subscribed to Overleaf through Overleaf v1
-	p
-		a.btn.btn-primary(href='/sign_in_to_v1?return_to=/users/edit%23status') Manage v1 Subscription
-	hr
-
 - if (v1Subscriptions.teams && v1Subscriptions.teams.length > 0)
 - if (v1Subscriptions.teams && v1Subscriptions.teams.length > 0)
 	-hasAnySubscription = true
 	-hasAnySubscription = true
 	for team in v1Subscriptions.teams
 	for team in v1Subscriptions.teams

+ 1 - 1
services/web/docker-compose.yml

@@ -17,7 +17,7 @@ services:
       PROJECT_HISTORY_ENABLED: 'true'
       PROJECT_HISTORY_ENABLED: 'true'
       ENABLED_LINKED_FILE_TYPES: 'url'
       ENABLED_LINKED_FILE_TYPES: 'url'
       LINKED_URL_PROXY: 'http://localhost:6543'
       LINKED_URL_PROXY: 'http://localhost:6543'
-      ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley'
+      ENABLED_LINKED_FILE_TYPES: 'url,project_file,project_output_file,mendeley,zotero'
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
       SHARELATEX_CONFIG: /app/test/acceptance/config/settings.test.coffee
       NODE_ENV: production
       NODE_ENV: production
     depends_on:
     depends_on:

BIN
services/web/public/img/other-brands/logo_ieee_white.png


+ 2 - 1
services/web/public/src/ide/file-tree/FileTreeManager.js

@@ -79,7 +79,8 @@ define([
               name: file.name,
               name: file.name,
               id: file._id,
               id: file._id,
               type: 'file',
               type: 'file',
-              linkedFileData
+              linkedFileData,
+              created: file.created
             })
             })
             return this.recalculateDocList()
             return this.recalculateDocList()
           })
           })

+ 2 - 2
services/web/public/stylesheets/components/tabs.less

@@ -16,7 +16,7 @@
       color: @link-color-alt;
       color: @link-color-alt;
       &:focus, &:hover {
       &:focus, &:hover {
         background-color: transparent!important;
         background-color: transparent!important;
-        border: 0!important;
+        border: 0;
         color: @link-hover-color-alt;
         color: @link-hover-color-alt;
       }
       }
     }
     }
@@ -36,4 +36,4 @@
     background-color: transparent!important;
     background-color: transparent!important;
     border: none!important;
     border: none!important;
   }
   }
-}
+}

+ 8 - 0
services/web/public/stylesheets/core/ol-ieee-variables.less

@@ -42,3 +42,11 @@
 @editor-toggler-hover-bg-color: @ieee-blue;
 @editor-toggler-hover-bg-color: @ieee-blue;
 
 
 @toggle-switch-highlight-color: @ieee-blue;
 @toggle-switch-highlight-color: @ieee-blue;
+
+@footer-link-color            : @link-color;
+@footer-link-hover-color      : @link-hover-color;
+
+@navbar-subdued-hover-color     : @ieee-blue;
+@navbar-default-link-hover-bg   : @ieee-blue;
+@navbar-default-link-hover-color: @ieee-blue;
+@navbar-default-link-active-bg  : @ieee-blue;

+ 38 - 0
services/web/public/stylesheets/ol-ieee-style.less

@@ -4,3 +4,41 @@
 @is-overleaf      : true;
 @is-overleaf      : true;
 @is-overleaf-light: false;
 @is-overleaf-light: false;
 @show-rich-text   : true;
 @show-rich-text   : true;
+
+@ieee-wedge: 30px;
+
+body > .portal-ieee {
+  padding-top: @header-height;
+}
+
+.portal-ieee {
+  .ieee-header {
+    background-color: @ieee-blue;
+    margin-bottom: @margin-xl;
+    padding-bottom: @padding-sm;
+    padding-top: @padding-sm;
+    h1 {
+      margin: 0;
+    }
+    .ieee-logo {
+      width: @navbar-brand-width;
+    }
+  }
+  .ieee-subheader {
+    background-color: @ieee-blue;
+    color: #ffffff;
+    line-height: 1;
+    padding: @padding-md @ieee-wedge;
+    position: relative;
+    &:after {
+      content: "";
+      display: block;
+      position: absolute;
+      border-style: solid;
+      left: -1px;
+      top: -1px;
+      border-color: @content-alt-bg-color transparent;
+      border-width: @ieee-wedge @ieee-wedge 0 0;
+    }
+  }
+}

+ 32 - 2
services/web/test/acceptance/coffee/SubscriptionTests.coffee

@@ -234,7 +234,8 @@ describe 'Subscriptions', ->
 			before (done) ->
 			before (done) ->
 				v1Id = MockV1Api.nextV1Id()
 				v1Id = MockV1Api.nextV1Id()
 				MockV1Api.setUser v1Id, {
 				MockV1Api.setUser v1Id, {
-					subscription: {}
+					subscription: {},
+					subscription_status: {}
 				}
 				}
 				MockV1Api.setAffiliations [{
 				MockV1Api.setAffiliations [{
 					email: 'confirmed-affiliation-email@stanford.example.edu'
 					email: 'confirmed-affiliation-email@stanford.example.edu'
@@ -281,6 +282,10 @@ describe 'Subscriptions', ->
 							name: 'Test team'
 							name: 'Test team'
 						}]
 						}]
 					}
 					}
+					subscription_status: @subscription_status = {
+						product: { 'mock': 'product' }
+						team: null
+					}
 				}
 				}
 				@user.setV1Id v1Id, (error) =>
 				@user.setV1Id v1Id, (error) =>
 					return done(error) if error?
 					return done(error) if error?
@@ -295,4 +300,29 @@ describe 'Subscriptions', ->
 				expect(@data.memberGroupSubscriptions).to.deep.equal []
 				expect(@data.memberGroupSubscriptions).to.deep.equal []
 
 
 			it 'should return a v1Subscriptions', ->
 			it 'should return a v1Subscriptions', ->
-				expect(@data.v1Subscriptions).to.deep.equal @subscription
+				expect(@data.v1Subscriptions).to.deep.equal @subscription
+
+			it 'should return a v1SubscriptionStatus', ->
+				expect(@data.v1SubscriptionStatus).to.deep.equal @subscription_status
+
+	describe.only 'canceling', ->
+		before (done) ->
+			@user = new User()
+			MockV1Api.setUser v1Id = MockV1Api.nextV1Id(), @v1_user = {}
+			async.series [
+				(cb) => @user.login(cb)
+				(cb) => @user.setV1Id(v1Id, cb)
+			], (error) =>
+				@user.request {
+					method: 'POST',
+					url: '/user/subscription/v1/cancel'
+				}, (error, @response) =>
+					return done(error) if error?
+					done()
+
+		it 'should tell v1 to cancel the subscription', ->
+			expect(@v1_user.canceled).to.equal true
+
+		it 'should redirect to the subscription dashboard', ->
+			expect(@response.statusCode).to.equal 302
+			expect(@response.headers.location).to.equal '/user/subscription'

+ 3 - 0
services/web/test/acceptance/coffee/helpers/MockDocUpdaterApi.coffee

@@ -38,6 +38,9 @@ module.exports = MockDocUpdaterApi =
 		app.post "/project/:project_id/doc/:doc_id", (req, res, next) =>
 		app.post "/project/:project_id/doc/:doc_id", (req, res, next) =>
 			res.sendStatus 204
 			res.sendStatus 204
 
 
+		app.delete "/project/:project_id", (req, res) =>
+			res.sendStatus 204
+
 		app.post "/project/:project_id/doc/:doc_id/flush", (req, res, next) =>
 		app.post "/project/:project_id/doc/:doc_id/flush", (req, res, next) =>
 			res.sendStatus 204
 			res.sendStatus 204
 
 

+ 14 - 0
services/web/test/acceptance/coffee/helpers/MockV1Api.coffee

@@ -53,6 +53,20 @@ module.exports = MockV1Api =
 			else
 			else
 				res.sendStatus 404
 				res.sendStatus 404
 
 
+		app.get "/api/v1/sharelatex/users/:v1_user_id/subscription_status", (req, res, next) =>
+			user = @users[req.params.v1_user_id]
+			if user?.subscription_status?
+				res.json user.subscription_status
+			else
+				res.sendStatus 404
+
+		app.delete "/api/v1/sharelatex/users/:v1_user_id/subscription", (req, res, next) =>
+			user = @users[req.params.v1_user_id]
+			if user?
+				user.canceled = true
+				res.sendStatus 200
+			else
+				res.sendStatus 404
 
 
 		app.post "/api/v1/sharelatex/users/:v1_user_id/sync", (req, res, next) =>
 		app.post "/api/v1/sharelatex/users/:v1_user_id/sync", (req, res, next) =>
 			@syncUserFeatures(req.params.v1_user_id)
 			@syncUserFeatures(req.params.v1_user_id)

+ 8 - 8
services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -491,10 +491,10 @@ describe "AuthenticationController", ->
 		beforeEach ->
 		beforeEach ->
 			@req.headers = {}
 			@req.headers = {}
 			@AuthenticationController.httpAuth = sinon.stub()
 			@AuthenticationController.httpAuth = sinon.stub()
-			@_setRedirect = sinon.spy(@AuthenticationController, '_setRedirectInSession')
+			@setRedirect = sinon.spy(@AuthenticationController, 'setRedirectInSession')
 
 
 		afterEach ->
 		afterEach ->
-			@_setRedirect.restore()
+			@setRedirect.restore()
 
 
 		describe "with white listed url", ->
 		describe "with white listed url", ->
 			beforeEach ->
 			beforeEach ->
@@ -540,7 +540,7 @@ describe "AuthenticationController", ->
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 				@AuthenticationController.requireGlobalLogin @req, @res, @next
 
 
 			it 'should have called setRedirectInSession', ->
 			it 'should have called setRedirectInSession', ->
-				@_setRedirect.callCount.should.equal 1
+				@setRedirect.callCount.should.equal 1
 
 
 			it "should redirect to the /login page", ->
 			it "should redirect to the /login page", ->
 				@res.redirectedTo.should.equal "/login"
 				@res.redirectedTo.should.equal "/login"
@@ -640,18 +640,18 @@ describe "AuthenticationController", ->
 			@callback.called.should.equal true
 			@callback.called.should.equal true
 
 
 
 
-	describe '_setRedirectInSession', ->
+	describe 'setRedirectInSession', ->
 		beforeEach ->
 		beforeEach ->
 			@req = {session: {}}
 			@req = {session: {}}
 			@req.path = "/somewhere"
 			@req.path = "/somewhere"
 			@req.query = {one: "1"}
 			@req.query = {one: "1"}
 
 
 		it 'should set redirect property on session', ->
 		it 'should set redirect property on session', ->
-			@AuthenticationController._setRedirectInSession(@req)
+			@AuthenticationController.setRedirectInSession(@req)
 			expect(@req.session.postLoginRedirect).to.equal "/somewhere?one=1"
 			expect(@req.session.postLoginRedirect).to.equal "/somewhere?one=1"
 
 
 		it 'should set the supplied value', ->
 		it 'should set the supplied value', ->
-			@AuthenticationController._setRedirectInSession(@req, '/somewhere/specific')
+			@AuthenticationController.setRedirectInSession(@req, '/somewhere/specific')
 			expect(@req.session.postLoginRedirect).to.equal "/somewhere/specific"
 			expect(@req.session.postLoginRedirect).to.equal "/somewhere/specific"
 
 
 		describe 'with a png', ->
 		describe 'with a png', ->
@@ -659,7 +659,7 @@ describe "AuthenticationController", ->
 				@req = {session: {}}
 				@req = {session: {}}
 
 
 			it 'should not set the redirect', ->
 			it 'should not set the redirect', ->
-				@AuthenticationController._setRedirectInSession(@req, '/something.png')
+				@AuthenticationController.setRedirectInSession(@req, '/something.png')
 				expect(@req.session.postLoginRedirect).to.equal undefined
 				expect(@req.session.postLoginRedirect).to.equal undefined
 
 
 		describe 'with a js path', ->
 		describe 'with a js path', ->
@@ -668,7 +668,7 @@ describe "AuthenticationController", ->
 				@req = {session: {}}
 				@req = {session: {}}
 
 
 			it 'should not set the redirect', ->
 			it 'should not set the redirect', ->
-				@AuthenticationController._setRedirectInSession(@req, '/js/something.js')
+				@AuthenticationController.setRedirectInSession(@req, '/js/something.js')
 				expect(@req.session.postLoginRedirect).to.equal undefined
 				expect(@req.session.postLoginRedirect).to.equal undefined
 
 
 	describe '_getRedirectFromSession', ->
 	describe '_getRedirectFromSession', ->

+ 31 - 4
services/web/test/unit/coffee/Project/ProjectDetailsHandlerTests.coffee

@@ -163,12 +163,14 @@ describe 'ProjectDetailsHandler', ->
 	describe "ensureProjectNameIsUnique", ->
 	describe "ensureProjectNameIsUnique", ->
 		beforeEach ->
 		beforeEach ->
 			@result = {
 			@result = {
-				owned: [{_id: 1, name:"name"}, {_id: 2, name: "name1"}, {_id: 3, name: "name11"}]
+				owned: [{_id: 1, name:"name"}, {_id: 2, name: "name1"}, {_id: 3, name: "name11"}, {_id: 100, name: "numeric"}]
 				readAndWrite: [{_id: 4, name:"name2"}, {_id: 5, name:"name22"}]
 				readAndWrite: [{_id: 4, name:"name2"}, {_id: 5, name:"name22"}]
 				readOnly: [{_id:6, name:"name3"}, {_id:7, name: "name33"}]
 				readOnly: [{_id:6, name:"name3"}, {_id:7, name: "name33"}]
 				tokenReadAndWrite: [{_id:8, name:"name4"}, {_id:9, name:"name44"}]
 				tokenReadAndWrite: [{_id:8, name:"name4"}, {_id:9, name:"name44"}]
 				tokenReadOnly: [{_id:10, name:"name5"}, {_id:11, name:"name55"}, {_id:12, name:"x".repeat(15)}]
 				tokenReadOnly: [{_id:10, name:"name5"}, {_id:11, name:"name55"}, {_id:12, name:"x".repeat(15)}]
 			}
 			}
+			for i in [1..20].concat([30..40])
+				@result.owned.push {_id: 100 + i, name: "numeric (#{i})"}
 			@ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, @result)
 			@ProjectGetter.findAllUsersProjects = sinon.stub().callsArgWith(2, null, @result)
 
 
 		it "should leave a unique name unchanged", (done) ->
 		it "should leave a unique name unchanged", (done) ->
@@ -196,9 +198,34 @@ describe 'ProjectDetailsHandler', ->
 				expect(changed).to.equal true
 				expect(changed).to.equal true
 				done()
 				done()
 
 
-		it "should return an error if the name cannot be made unique", (done) ->
-			@handler.ensureProjectNameIsUnique @user_id, "name", ["1", "5", "55"], (error, name, changed) ->
-				expect(error).to.eql new Errors.InvalidNameError("Project name could not be made unique")
+		it "should use a numeric index if no suffix is supplied", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "name1", [], (error, name, changed) ->
+				expect(name).to.equal "name1 (1)"
+				expect(changed).to.equal true
+				done()
+
+		it "should use a numeric index if all suffixes are exhausted", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "name", ["1", "11"], (error, name, changed) ->
+				expect(name).to.equal "name (1)"
+				expect(changed).to.equal true
+				done()
+
+		it "should find the next lowest available numeric index for the base name", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "numeric", [], (error, name, changed) ->
+				expect(name).to.equal "numeric (21)"
+				expect(changed).to.equal true
+				done()
+
+		it "should find the next available numeric index when a numeric index is already present", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "numeric (5)", [], (error, name, changed) ->
+				expect(name).to.equal "numeric (21)"
+				expect(changed).to.equal true
+				done()
+
+		it "should not find a numeric index lower than the one already present", (done) ->
+			@handler.ensureProjectNameIsUnique @user_id, "numeric (31)", [], (error, name, changed) ->
+				expect(name).to.equal "numeric (41)"
+				expect(changed).to.equal true
 				done()
 				done()
 
 
 	describe "fixProjectName", ->
 	describe "fixProjectName", ->

+ 1 - 0
services/web/test/unit/coffee/Subscription/SubscriptionControllerTests.coffee

@@ -79,6 +79,7 @@ describe "SubscriptionController", ->
 			"./RecurlyWrapper": @RecurlyWrapper = {}
 			"./RecurlyWrapper": @RecurlyWrapper = {}
 			"./FeaturesUpdater": @FeaturesUpdater = {}
 			"./FeaturesUpdater": @FeaturesUpdater = {}
 			"./GroupPlansData": @GroupPlansData = {}
 			"./GroupPlansData": @GroupPlansData = {}
+			"./V1SubscriptionManager": @V1SubscriptionManager = {}
 
 
 
 
 		@res = new MockResponse()
 		@res = new MockResponse()

+ 4 - 4
services/web/test/unit/coffee/SudoMode/SudoModeMiddlewearTests.coffee

@@ -13,7 +13,7 @@ describe 'SudoModeMiddlewear', ->
 			isSudoModeActive: sinon.stub()
 			isSudoModeActive: sinon.stub()
 		@AuthenticationController =
 		@AuthenticationController =
 			getLoggedInUserId: sinon.stub().returns(@userId)
 			getLoggedInUserId: sinon.stub().returns(@userId)
-			_setRedirectInSession: sinon.stub()
+			setRedirectInSession: sinon.stub()
 		@SudoModeMiddlewear = SandboxedModule.require modulePath, requires:
 		@SudoModeMiddlewear = SandboxedModule.require modulePath, requires:
 			'./SudoModeHandler': @SudoModeHandler
 			'./SudoModeHandler': @SudoModeHandler
 			'../Authentication/AuthenticationController': @AuthenticationController
 			'../Authentication/AuthenticationController': @AuthenticationController
@@ -54,7 +54,7 @@ describe 'SudoModeMiddlewear', ->
 
 
 		describe 'when sudo mode is not active', ->
 		describe 'when sudo mode is not active', ->
 			beforeEach ->
 			beforeEach ->
-				@AuthenticationController._setRedirectInSession = sinon.stub()
+				@AuthenticationController.setRedirectInSession = sinon.stub()
 				@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId)
 				@AuthenticationController.getLoggedInUserId = sinon.stub().returns(@userId)
 				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, false)
 				@SudoModeHandler.isSudoModeActive = sinon.stub().callsArgWith(1, null, false)
 
 
@@ -71,8 +71,8 @@ describe 'SudoModeMiddlewear', ->
 
 
 			it 'should set redirect in session', (done) ->
 			it 'should set redirect in session', (done) ->
 				@call () =>
 				@call () =>
-					@AuthenticationController._setRedirectInSession.callCount.should.equal 1
-					@AuthenticationController._setRedirectInSession.calledWith(@req).should.equal true
+					@AuthenticationController.setRedirectInSession.callCount.should.equal 1
+					@AuthenticationController.setRedirectInSession.calledWith(@req).should.equal true
 					done()
 					done()
 
 
 			it 'should redirect to the password-prompt page', (done) ->
 			it 'should redirect to the password-prompt page', (done) ->

+ 4 - 4
services/web/test/unit/coffee/TokenAccess/TokenAccessControllerTests.coffee

@@ -61,7 +61,7 @@ describe "TokenAccessController", ->
 				@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 				@TokenAccessHandler.addReadAndWriteUserToProject = sinon.stub()
 					.callsArgWith(2, null)
 					.callsArgWith(2, null)
 				@ProjectController.loadEditor = sinon.stub()
 				@ProjectController.loadEditor = sinon.stub()
-				@AuthenticationController._setRedirectInSession = sinon.stub()
+				@AuthenticationController.setRedirectInSession = sinon.stub()
 				@TokenAccessController.readAndWriteToken @req, @res, @next
 				@TokenAccessController.readAndWriteToken @req, @res, @next
 
 
 			it 'should try to find a project with this token', (done) ->
 			it 'should try to find a project with this token', (done) ->
@@ -173,7 +173,7 @@ describe "TokenAccessController", ->
 						.callsArgWith(2, null)
 						.callsArgWith(2, null)
 					@ProjectController.loadEditor = sinon.stub()
 					@ProjectController.loadEditor = sinon.stub()
 					@TokenAccessHandler.grantSessionTokenAccess = sinon.stub()
 					@TokenAccessHandler.grantSessionTokenAccess = sinon.stub()
-					@AuthenticationController._setRedirectInSession = sinon.stub()
+					@AuthenticationController.setRedirectInSession = sinon.stub()
 					@TokenAccessController.readAndWriteToken @req, @res, @next
 					@TokenAccessController.readAndWriteToken @req, @res, @next
 
 
 				it 'should not add the user to the project with read-write access', (done) ->
 				it 'should not add the user to the project with read-write access', (done) ->
@@ -192,8 +192,8 @@ describe "TokenAccessController", ->
 					done()
 					done()
 
 
 				it 'should set redirect in session', (done) ->
 				it 'should set redirect in session', (done) ->
-					expect(@AuthenticationController._setRedirectInSession.callCount).to.equal 1
-					expect(@AuthenticationController._setRedirectInSession.calledWith(@req)).to.equal true
+					expect(@AuthenticationController.setRedirectInSession.callCount).to.equal 1
+					expect(@AuthenticationController.setRedirectInSession.calledWith(@req)).to.equal true
 					done()
 					done()
 
 
 				it 'should redirect to restricted page', (done) ->
 				it 'should redirect to restricted page', (done) ->

+ 4 - 4
services/web/test/unit/coffee/User/UserPagesControllerTests.coffee

@@ -28,7 +28,7 @@ describe "UserPagesController", ->
 			getLoggedInUserId: sinon.stub().returns(@user._id)
 			getLoggedInUserId: sinon.stub().returns(@user._id)
 			getSessionUser: sinon.stub().returns(@user)
 			getSessionUser: sinon.stub().returns(@user)
 			_getRedirectFromSession: sinon.stub()
 			_getRedirectFromSession: sinon.stub()
-			_setRedirectInSession: sinon.stub()
+			setRedirectInSession: sinon.stub()
 		@UserPagesController = SandboxedModule.require modulePath, requires:
 		@UserPagesController = SandboxedModule.require modulePath, requires:
 			"settings-sharelatex": @settings
 			"settings-sharelatex": @settings
 			"logger-sharelatex":
 			"logger-sharelatex":
@@ -92,13 +92,13 @@ describe "UserPagesController", ->
 
 
 			beforeEach ->
 			beforeEach ->
 				@AuthenticationController._getRedirectFromSession = sinon.stub().returns(null)
 				@AuthenticationController._getRedirectFromSession = sinon.stub().returns(null)
-				@AuthenticationController._setRedirectInSession = sinon.stub()
+				@AuthenticationController.setRedirectInSession = sinon.stub()
 				@req.query.redir = '/somewhere/in/particular'
 				@req.query.redir = '/somewhere/in/particular'
 
 
 			it 'should set a redirect', (done) ->
 			it 'should set a redirect', (done) ->
 				@res.render = (page) =>
 				@res.render = (page) =>
-					@AuthenticationController._setRedirectInSession.callCount.should.equal 1
-					expect(@AuthenticationController._setRedirectInSession.lastCall.args[1]).to.equal @req.query.redir
+					@AuthenticationController.setRedirectInSession.callCount.should.equal 1
+					expect(@AuthenticationController.setRedirectInSession.lastCall.args[1]).to.equal @req.query.redir
 					done()
 					done()
 				@UserPagesController.loginPage @req, @res
 				@UserPagesController.loginPage @req, @res
 
 

+ 49 - 31
services/web/test/unit/coffee/UserMembership/UserMembershipAuthorizationTests.coffee

@@ -30,10 +30,9 @@ describe "UserMembershipAuthorization", ->
 				log: ->
 				log: ->
 				err: ->
 				err: ->
 
 
-	describe 'requireEntityAccess', ->
+	describe 'requireAccessToEntity', ->
 		it 'get entity', (done) ->
 		it 'get entity', (done) ->
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'group'
-			middlewear @req, null, (error) =>
+			@UserMembershipAuthorization.requireGroupAccess @req, null, (error) =>
 				expect(error).to.not.extist
 				expect(error).to.not.extist
 				sinon.assert.calledWithMatch(
 				sinon.assert.calledWithMatch(
 					@UserMembershipHandler.getEntity,
 					@UserMembershipHandler.getEntity,
@@ -45,19 +44,9 @@ describe "UserMembershipAuthorization", ->
 				expect(@req.entityConfig).to.exist
 				expect(@req.entityConfig).to.exist
 				done()
 				done()
 
 
-		it 'handle unknown entity', (done) ->
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'foo'
-			middlewear @req, null, (error) =>
-				expect(error).to.extist
-				expect(error).to.be.an.instanceof(Errors.NotFoundError)
-				sinon.assert.notCalled(@UserMembershipHandler.getEntity)
-				expect(@req.entity).to.not.exist
-				done()
-
 		it 'handle entity not found', (done) ->
 		it 'handle entity not found', (done) ->
 			@UserMembershipHandler.getEntity.yields(null, null)
 			@UserMembershipHandler.getEntity.yields(null, null)
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'institution'
-			middlewear @req, null, (error) =>
+			@UserMembershipAuthorization.requireGroupAccess @req, null, (error) =>
 				expect(error).to.extist
 				expect(error).to.extist
 				sinon.assert.called(@AuthorizationMiddlewear.redirectToRestricted)
 				sinon.assert.called(@AuthorizationMiddlewear.redirectToRestricted)
 				sinon.assert.called(@UserMembershipHandler.getEntity)
 				sinon.assert.called(@UserMembershipHandler.getEntity)
@@ -66,34 +55,63 @@ describe "UserMembershipAuthorization", ->
 
 
 		it 'handle anonymous user', (done) ->
 		it 'handle anonymous user', (done) ->
 			@AuthenticationController.getSessionUser.returns(null)
 			@AuthenticationController.getSessionUser.returns(null)
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'institution'
-			middlewear @req, null, (error) =>
+			@UserMembershipAuthorization.requireGroupAccess @req, null, (error) =>
 				expect(error).to.extist
 				expect(error).to.extist
 				sinon.assert.called(@AuthorizationMiddlewear.redirectToRestricted)
 				sinon.assert.called(@AuthorizationMiddlewear.redirectToRestricted)
 				sinon.assert.notCalled(@UserMembershipHandler.getEntity)
 				sinon.assert.notCalled(@UserMembershipHandler.getEntity)
 				expect(@req.entity).to.not.exist
 				expect(@req.entity).to.not.exist
 				done()
 				done()
 
 
-		it 'can override entity id', (done) ->
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'group', 'entity-id-override'
-			middlewear @req, null, (error) =>
+	describe 'requireEntityAccess', ->
+		it 'handle team access', (done) ->
+			@UserMembershipAuthorization.requireTeamAccess @req, null, (error) =>
+				expect(error).to.not.extist
+				sinon.assert.calledWithMatch(
+					@UserMembershipHandler.getEntity,
+					@req.params.id,
+					fields: primaryKey: 'overleaf.id'
+				)
+				done()
+
+		it 'handle group access', (done) ->
+			@UserMembershipAuthorization.requireGroupAccess @req, null, (error) =>
 				expect(error).to.not.extist
 				expect(error).to.not.extist
 				sinon.assert.calledWithMatch(
 				sinon.assert.calledWithMatch(
 					@UserMembershipHandler.getEntity,
 					@UserMembershipHandler.getEntity,
-					'entity-id-override',
+					@req.params.id,
+					translations: title: 'group_account'
+				)
+				done()
+
+		it 'handle group managers access', (done) ->
+			@UserMembershipAuthorization.requireGroupManagersAccess @req, null, (error) =>
+				expect(error).to.not.extist
+				sinon.assert.calledWithMatch(
+					@UserMembershipHandler.getEntity,
+					@req.params.id,
+					translations: subtitle: 'managers_management'
 				)
 				)
 				done()
 				done()
 
 
-		it "doesn't cache entity id between requests", (done) ->
-			middlewear = @UserMembershipAuthorization.requireEntityAccess 'group'
+		it 'handle institution access', (done) ->
+			@UserMembershipAuthorization.requireInstitutionAccess @req, null, (error) =>
+				expect(error).to.not.extist
+				sinon.assert.calledWithMatch(
+					@UserMembershipHandler.getEntity,
+					@req.params.id,
+					modelName: 'Institution',
+				)
+				done()
+
+		it 'handle graph access', (done) ->
+			@req.query.resource_id = 'mock-resource-id'
+			@req.query.resource_type = 'institution'
+			middlewear = @UserMembershipAuthorization.requireGraphAccess
 			middlewear @req, null, (error) =>
 			middlewear @req, null, (error) =>
 				expect(error).to.not.extist
 				expect(error).to.not.extist
-				lastCallArs = @UserMembershipHandler.getEntity.lastCall.args
-				expect(lastCallArs[0]).to.equal @req.params.id
-				newEntityId = 'another-mock-id'
-				@req.params.id = newEntityId
-				middlewear @req, null, (error) =>
-					expect(error).to.not.extist
-					lastCallArs = @UserMembershipHandler.getEntity.lastCall.args
-					expect(lastCallArs[0]).to.equal newEntityId
-					done()
+				sinon.assert.calledWithMatch(
+					@UserMembershipHandler.getEntity,
+					@req.query.resource_id,
+					modelName: 'Institution',
+				)
+				done()

+ 17 - 0
services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee

@@ -29,6 +29,11 @@ describe 'UserMembershipHandler', ->
 			v1Id: 123
 			v1Id: 123
 			managerIds: [ObjectId(), ObjectId(), ObjectId()]
 			managerIds: [ObjectId(), ObjectId(), ObjectId()]
 			update: sinon.stub().yields(null)
 			update: sinon.stub().yields(null)
+		@publisher =
+			_id: 'mock-publisher-id'
+			slug: 'slug'
+			managerIds: [ObjectId(), ObjectId()]
+			update: sinon.stub().yields(null)
 
 
 		@UserMembershipViewModel =
 		@UserMembershipViewModel =
 			buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'})
 			buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'})
@@ -39,12 +44,15 @@ describe 'UserMembershipHandler', ->
 			findOne: sinon.stub().yields(null, @institution)
 			findOne: sinon.stub().yields(null, @institution)
 		@Subscription =
 		@Subscription =
 			findOne: sinon.stub().yields(null, @subscription)
 			findOne: sinon.stub().yields(null, @subscription)
+		@Publisher =
+			findOne: sinon.stub().yields(null, @publisher)
 		@UserMembershipHandler = SandboxedModule.require modulePath, requires:
 		@UserMembershipHandler = SandboxedModule.require modulePath, requires:
 			'./UserMembershipViewModel': @UserMembershipViewModel
 			'./UserMembershipViewModel': @UserMembershipViewModel
 			'../User/UserGetter': @UserGetter
 			'../User/UserGetter': @UserGetter
 			'../Errors/Errors': Errors
 			'../Errors/Errors': Errors
 			'../../models/Institution': Institution: @Institution
 			'../../models/Institution': Institution: @Institution
 			'../../models/Subscription': Subscription: @Subscription
 			'../../models/Subscription': Subscription: @Subscription
+			'../../models/Publisher': Publisher: @Publisher
 			'logger-sharelatex':
 			'logger-sharelatex':
 				log: -> 
 				log: -> 
 				err: ->
 				err: ->
@@ -94,6 +102,15 @@ describe 'UserMembershipHandler', ->
 					expect(error).to.not.be.an.instanceof(Errors.NotFoundError)
 					expect(error).to.not.be.an.instanceof(Errors.NotFoundError)
 					done()
 					done()
 
 
+		describe 'publishers', ->
+			it 'get publisher', (done) ->
+				@UserMembershipHandler.getEntity @publisher.slug, EntityConfigs.publisher, @user, (error, institution) =>
+					should.not.exist(error)
+					expectedQuery = slug: @publisher.slug, managerIds: ObjectId(@user._id)
+					assertCalledWith(@Publisher.findOne, expectedQuery)
+					expect(institution).to.equal @publisher
+					done()
+
 	describe 'getUsers', ->
 	describe 'getUsers', ->
 		describe 'group', ->
 		describe 'group', ->
 			it 'build view model for all users', (done) ->
 			it 'build view model for all users', (done) ->