فهرست منبع

add UserMembership logic

Tim Alby 7 سال پیش
والد
کامیت
48e5c6b523
22فایلهای تغییر یافته به همراه669 افزوده شده و 143 حذف شده
  1. 9 0
      services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee
  2. 0 12
      services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee
  3. 3 39
      services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee
  4. 2 1
      services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee
  5. 4 1
      services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee
  6. 6 3
      services/web/app/coffee/Features/User/UserGetter.coffee
  7. 65 0
      services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee
  8. 85 0
      services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee
  9. 26 0
      services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee
  10. 45 0
      services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee
  11. 11 0
      services/web/app/coffee/models/Institution.coffee
  12. 2 0
      services/web/app/coffee/router.coffee
  13. 13 15
      services/web/app/views/user_membership/index.pug
  14. 1 1
      services/web/public/coffee/main.coffee
  15. 9 6
      services/web/public/coffee/main/user-membership.coffee
  16. 29 0
      services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee
  17. 0 19
      services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee
  18. 0 46
      services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee
  19. 9 0
      services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee
  20. 95 0
      services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee
  21. 172 0
      services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee
  22. 83 0
      services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee

+ 9 - 0
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

+ 0 - 12
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"

+ 3 - 39
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
-	}

+ 2 - 1
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

+ 4 - 1
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

+ 6 - 3
services/web/app/coffee/Features/User/UserGetter.coffee

@@ -66,15 +66,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) ->

+ 65 - 0
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"

+ 85 - 0
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

+ 26 - 0
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)

+ 45 - 0
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)

+ 11 - 0
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

+ 2 - 0
services/web/app/coffee/router.coffee

@@ -50,6 +50,7 @@ TokenAccessController = require('./Features/TokenAccess/TokenAccessController')
 Features = require('./infrastructure/Features')
 LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter'
 TemplatesRouter = require './Features/Templates/TemplatesRouter'
+UserMembershipRouter = require './Features/UserMembership/UserMembershipRouter'
 
 logger = require("logger-sharelatex")
 _ = require("underscore")
@@ -84,6 +85,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)
 

+ 13 - 15
services/web/app/views/subscriptions/group_admin.pug → 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:"<strong>{{ users.length }}</strong>", groupSize:"<strong>{{ groupSize }}</strong>"})}
+								small(ng-show="groupSize && selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"<strong>{{ users.length }}</strong>", groupSize:"<strong>{{ groupSize }}</strong>"})}
 								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'};

+ 1 - 1
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"

+ 9 - 6
services/web/public/coffee/main/group-members.coffee → 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()

+ 29 - 0
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()

+ 0 - 19
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 ->

+ 0 - 46
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"

+ 9 - 0
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()

+ 95 - 0
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()

+ 172 - 0
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()

+ 83 - 0
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()