diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee new file mode 100644 index 0000000000..013bd2ac00 --- /dev/null +++ b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee @@ -0,0 +1,9 @@ +Institution = require('../../models/Institution').Institution +logger = require("logger-sharelatex") +ObjectId = require('mongoose').Types.ObjectId + +module.exports = InstitutionLocator = + + findManagedInstitution: (managerId, callback)-> + logger.log managerId: managerId, "finding managed Institution" + Institution.findOne managerIds: managerId, callback diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee index 0fb7b6e7c9..3ebd096b70 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee @@ -52,18 +52,6 @@ module.exports = return res.sendStatus 500 res.send() - renderSubscriptionGroupAdminPage: (req, res, next)-> - user_id = AuthenticationController.getLoggedInUserId(req) - getManagedSubscription user_id, (error, subscription)-> - return next(error) if error? - if !subscription?.groupPlan - return res.redirect("/user/subscription") - SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)-> - res.render "subscriptions/group_admin", - title: 'group_admin' - users: users - subscription: subscription - exportGroupCsv: (req, res)-> user_id = AuthenticationController.getLoggedInUserId(req) logger.log user_id: user_id, "exporting group csv" diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index badcde4c36..bb849fa7bd 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -11,6 +11,7 @@ TeamInvitesHandler = require("./TeamInvitesHandler") EmailHandler = require("../Email/EmailHandler") settings = require("settings-sharelatex") NotificationsBuilder = require("../Notifications/NotificationsBuilder") +UserMembershipViewModel = require("../UserMembership/UserMembershipViewModel") module.exports = SubscriptionGroupHandler = @@ -31,12 +32,12 @@ module.exports = SubscriptionGroupHandler = logger.err err:err, "error adding user to group" return callback(err) NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read() - userViewModel = buildUserViewModel(user) + userViewModel = UserMembershipViewModel.build(user) callback(err, userViewModel) else TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) -> return callback(err) if err? - userViewModel = buildEmailInviteViewModel(newEmail) + userViewModel = UserMembershipViewModel.build(newEmail) callback(err, userViewModel) removeUserFromGroup: (subscriptionId, userToRemove_id, callback)-> @@ -51,28 +52,6 @@ module.exports = SubscriptionGroupHandler = replaceInArray Subscription, "member_ids", oldId, newId, callback - getPopulatedListOfMembers: (subscriptionId, callback)-> - SubscriptionLocator.getSubscription subscriptionId, (err, subscription)-> - users = [] - - for email in subscription.invited_emails or [] - users.push buildEmailInviteViewModel(email) - - for teamInvite in subscription.teamInvites or [] - users.push buildEmailInviteViewModel(teamInvite.email) - - jobs = _.map subscription.member_ids, (user_id)-> - return (cb)-> - UserGetter.getUser user_id, (err, user)-> - if err? or !user? - users.push _id:user_id - return cb() - userViewModel = buildUserViewModel(user) - users.push(userViewModel) - cb() - async.series jobs, (err)-> - callback(err, users) - isUserPartOfGroup: (user_id, subscription_id, callback=(err, partOfGroup)->)-> SubscriptionLocator.getSubscriptionByMemberIdAndId user_id, subscription_id, (err, subscription)-> if subscription? @@ -99,18 +78,3 @@ replaceInArray = (model, property, oldValue, newValue, callback) -> model.update query, { $addToSet: setNewValue }, { multi: true }, (error) -> return callback(error) if error? model.update query, { $pull: setOldValue }, { multi: true }, callback - -buildUserViewModel = (user)-> - u = - email: user.email - first_name: user.first_name - last_name: user.last_name - invite: user.holdingAccount - _id: user._id - return u - -buildEmailInviteViewModel = (email) -> - return { - email: email - invite: true - } diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee index 8950e3c5bd..79954b33e0 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee @@ -20,7 +20,8 @@ module.exports = webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription - webRouter.get '/subscription/group', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSubscriptionGroupAdminPage + webRouter.get '/subscription/group', AuthenticationController.requireLogin(), (req, res, next) -> + res.redirect('/manage/group/members') # legacy route webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index edcc58d57e..299320f061 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -55,7 +55,10 @@ module.exports = SubscriptionUpdater = if err? logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group" return callback(err) - FeaturesUpdater.refreshFeatures user_id, callback + UserGetter.getUserOrUserStubById user_id, {}, (error, user, isStub) -> + return callback(error) if error + return callback() if isStub + FeaturesUpdater.refreshFeatures user_id, callback deleteWithV1Id: (v1TeamId, callback)-> Subscription.deleteOne { "overleaf.id": v1TeamId }, callback diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index 4291cda6b7..e22ea204fd 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/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) -> diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee new file mode 100644 index 0000000000..bc673e1d5c --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee @@ -0,0 +1,65 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +UserMembershipHandler = require('./UserMembershipHandler') +logger = require("logger-sharelatex") + +module.exports = + index: (entityName, req, res, next)-> + userId = AuthenticationController.getLoggedInUserId(req) + + UserMembershipHandler.getEntity entityName, userId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.getUsers entityName, entity, (error, users)-> + return next(error) if error? + res.render "user_membership/index", + users: users + entity: entity + translations: getTranslationsFor(entityName) + paths: getPathsFor(entityName) + + add: (entityName, req, res, next)-> + userId = AuthenticationController.getLoggedInUserId(req) + email = req.body.email + return res.sendStatus 422 unless email + + UserMembershipHandler.getEntity entityName, userId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.addUser entityName, entity, email, (error, user)-> + return next(error) if error? + res.json(user: user) + + remove: (entityName, req, res, next)-> + loggedInUserId = AuthenticationController.getLoggedInUserId(req) + userId = req.params.userId + + UserMembershipHandler.getEntity entityName, loggedInUserId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.removeUser entityName, entity, userId, (error, user)-> + return next(error) if error? + res.send() + +getTranslationsFor = (entityName) -> + switch entityName + when 'group' + title: 'group_account' + remove: 'remove_from_group' + when 'groupManagers' + title: 'group_managers' + remove: 'remove_manager' + when 'institution' + title: 'institution_managers' + remove: 'remove_manager' + + +getPathsFor = (entityName) -> + switch entityName + when 'group' + addMember: '/subscription/invites' + removeMember: '/subscription/group/user' + removeInvite: '/subscription/invites' + exportMembers: '/subscription/group/export' + when 'groupManagers' + addMember: "/manage/group/managers" + removeMember: "/manage/group/managers" + when 'institution' + addMember: "/manage/institution/managers" + removeMember: "/manage/institution/managers" diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee new file mode 100644 index 0000000000..fa7b75fcb9 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee @@ -0,0 +1,85 @@ +async = require("async") +Errors = require('../Errors/Errors') +SubscriptionLocator = require('../Subscription/SubscriptionLocator') +InstitutionsLocator = require('../Institutions/InstitutionsLocator') +UserMembershipViewModel = require('./UserMembershipViewModel') +UserGetter = require('../User/UserGetter') +logger = require('logger-sharelatex') + +module.exports = + getEntity: (entityName, userId, callback = (error, entity) ->) -> + switch entityName + when 'group' then getGroupSubscription(userId, callback) + when 'groupManagers' + getGroupSubscription userId, (error, subscription) -> + subscription.membersLimit = null if subscription # managers are unlimited + callback(error, subscription) + when 'institution' then getInstitution(userId, callback) + else callback(new Errors.NotFoundError("No such entity: #{entityName}")) + + getUsers: (entityName, entity, callback = (error, users) ->) -> + attributes = switch entityName + when 'group' then ['invited_emails', 'teamInvites', 'member_ids'] + when 'groupManagers' then ['manager_ids'] + when 'institution' then ['managerIds'] + getPopulatedListOfMembers(entity, attributes, callback) + + addUser: (entityName, entity, email, callback = (error, user) ->) -> + attribute = switch entityName + when 'groupManagers' then 'manager_ids' + when 'institution' then 'managerIds' + unless attribute + return callback(new Errors.NotFoundError("Cannot add user to entity: #{entityName}")) + UserGetter.getUserByAnyEmail email, (error, user) -> + error ||= new Errors.NotFoundError("No user found with email #{email}") unless user + return callback(error) if error? + addUserToEntity entity, attribute, user, (error) -> + callback(error, UserMembershipViewModel.build(user)) + + removeUser: (entityName, entity, userId, callback = (error) ->) -> + attribute = switch entityName + when 'groupManagers' then 'manager_ids' + when 'institution' then 'managerIds' + else callback(new Errors.NotFoundError("Cannot remove user from entity: #{entityName}")) + removeUserFromEntity entity, attribute, userId, callback + +getGroupSubscription = (managerId, callback = (error, subscription) ->) -> + SubscriptionLocator.findManagedSubscription managerId, (err, subscription)-> + if subscription? and subscription.groupPlan + logger.log managerId: managerId, 'got managed subscription' + else + err ||= new Errors.NotFoundError("No subscription found managed by user #{managerId}") + + callback(err, subscription) + +getInstitution = (managerId, callback = (error, institution) ->) -> + InstitutionsLocator.findManagedInstitution managerId, (err, institution)-> + if institution? + logger.log managerId: managerId, 'got managed subscription' + else + err ||= new Errors.NotFoundError("No institution found managed by user #{managerId}") + + callback(err, institution) + +getPopulatedListOfMembers = (entity, attributes, callback = (error, users)->)-> + userObjects = [] + + for attribute in attributes + for userObject in entity[attribute] or [] + # userObject can be an email as String, a user id as ObjectId or an + # invite as Object with an email attribute as String. We want to pass to + # UserMembershipViewModel either an email as (String) or a user id (ObjectId) + userIdOrEmail = userObject.email || userObject + userObjects.push userIdOrEmail + + async.map userObjects, UserMembershipViewModel.buildAsync, callback + +addUserToEntity = (entity, attribute, user, callback = (error)->) -> + fieldUpdate = {} + fieldUpdate[attribute] = user._id + entity.update { $addToSet: fieldUpdate }, callback + +removeUserFromEntity = (entity, attribute, userId, callback = (error)->) -> + fieldUpdate = {} + fieldUpdate[attribute] = userId + entity.update { $pull: fieldUpdate }, callback diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee new file mode 100644 index 0000000000..223b412020 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee @@ -0,0 +1,26 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +UserMembershipController = require './UserMembershipController' + +module.exports = + apply: (webRouter) -> + webRouter.get '/manage/group/members', + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.index('group', req, res, next) + + + regularEntitites = + group: 'groupManagers' + institution: 'institution' + for pathName, entityName of regularEntitites + do (pathName, entityName) -> + webRouter.get "/manage/#{pathName}/managers", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.index(entityName, req, res, next) + + webRouter.post "/manage/#{pathName}/managers", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.add(entityName, req, res, next) + + webRouter.delete "/manage/#{pathName}/managers/:userId", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.remove(entityName, req, res, next) diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee new file mode 100644 index 0000000000..ac5ba42168 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee @@ -0,0 +1,45 @@ +ObjectId = require('mongojs').ObjectId +UserGetter = require('../User/UserGetter') + +module.exports = UserMembershipViewModel = + build: (userOrEmail) -> + if userOrEmail._id + buildUserViewModel userOrEmail + else + buildUserViewModelWithEmail userOrEmail + + + buildAsync: (userOrIdOrEmail, callback = (error, viewModel)->) -> + unless userOrIdOrEmail instanceof ObjectId + # userOrIdOrEmail is a user or an email and can be parsed by #build + return callback(null, UserMembershipViewModel.build(userOrIdOrEmail)) + + userId = userOrIdOrEmail + projection = { email: 1, first_name: 1, last_name: 1 } + UserGetter.getUserOrUserStubById userId, projection, (error, user, isStub) -> + if error? or !user? + return callback(null, buildUserViewModelWithId(userId.toString())) + if isStub + return callback(null, buildUserViewModelWithStub(user)) + callback(null, buildUserViewModel(user)) + + +buildUserViewModel = (user, isInvite = false) -> + _id: user._id or null + email: user.email or null + first_name: user.first_name or null + last_name: user.last_name or null + invite: isInvite + + +buildUserViewModelWithEmail = (email) -> + buildUserViewModel({ email }, true) + + +buildUserViewModelWithStub = (user) -> + # user stubs behave as invites + buildUserViewModel(user, true) + + +buildUserViewModelWithId = (id) -> + buildUserViewModel({ _id: id }, false) diff --git a/services/web/app/coffee/models/Institution.coffee b/services/web/app/coffee/models/Institution.coffee new file mode 100644 index 0000000000..decf114a96 --- /dev/null +++ b/services/web/app/coffee/models/Institution.coffee @@ -0,0 +1,11 @@ +mongoose = require 'mongoose' +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +InstitutionSchema = new Schema + v1Id: { type: Number, required: true } + managerIds: [ type:ObjectId, ref:'User' ] + +mongoose.model 'Institution', InstitutionSchema +exports.Institution = mongoose.model 'Institution' +exports.InstitutionSchema = InstitutionSchema diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index 21fe396da9..8c44f505cb 100644 --- a/services/web/app/coffee/router.coffee +++ b/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) diff --git a/services/web/app/views/subscriptions/group_admin.pug b/services/web/app/views/user_membership/index.pug similarity index 68% rename from services/web/app/views/subscriptions/group_admin.pug rename to services/web/app/views/user_membership/index.pug index cdb18a0320..4a512a1c11 100644 --- a/services/web/app/views/subscriptions/group_admin.pug +++ b/services/web/app/views/user_membership/index.pug @@ -5,16 +5,16 @@ block content .container .row .col-md-10.col-md-offset-1 - .card(ng-controller="SubscriptionGroupMembersController") + .card(ng-controller="UserMembershipController") .page-header .pull-right(ng-cloak) - small(ng-show="selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})} + small(ng-show="groupSize && selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})} a.btn.btn-danger( href, ng-show="selectedUsers.length > 0" ng-click="removeMembers()" - ) #{translate("remove_from_group")} - h1 #{translate("group_account")} + ) #{translate(translations.remove)} + h1 #{translate(translations.title)} .row-spaced-small ul.list-unstyled.structured-list( @@ -35,7 +35,7 @@ block content span.header #{translate("accepted_invite")} li.container-fluid( ng-repeat="user in users | orderBy:'email':true", - ng-controller="SubscriptionGroupMemberListItemController" + ng-controller="UserMembershipListItemController" ) .row .col-md-5 @@ -60,7 +60,7 @@ block content small #{translate("no_members")} hr - div(ng-if="users.length < groupSize", ng-cloak) + div(ng-if="!groupSize || users.length < groupSize", ng-cloak) p.small #{translate("add_more_members")} form.form .row @@ -74,18 +74,16 @@ block content ) .col-xs-4 button.btn.btn-primary(ng-click="addMembers()") #{translate("add")} - .col-xs-2 - a(href="/subscription/group/export") Export CSV + .col-xs-2(ng-if="paths.exportMembers", ng-cloak) + a(href=paths.exportMembers) Export CSV - div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak) + div(ng-if="groupSize && users.length >= groupSize && users.length > 0", ng-cloak) .row - .col-xs-2.col-xs-offset-10 - a(href="/subscription/group/export") Export CSV + .col-xs-2.col-xs-offset-10(ng-if="paths.exportMembers", ng-cloak) + a(href=paths.exportMembers) Export CSV script(type="text/javascript"). window.users = !{JSON.stringify(users)}; - window.groupSize = #{subscription.membersLimit}; - - - + window.paths = !{JSON.stringify(paths)}; + window.groupSize = #{entity.membersLimit || 'null'}; diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index fc62c6bc38..49b211b0aa 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -5,7 +5,7 @@ define [ "main/clear-sessions" "main/account-upgrade" "main/plans" - "main/group-members" + "main/user-membership" "main/scribtex-popup" "main/event" "main/bonus" diff --git a/services/web/public/coffee/main/group-members.coffee b/services/web/public/coffee/main/user-membership.coffee similarity index 75% rename from services/web/public/coffee/main/group-members.coffee rename to services/web/public/coffee/main/user-membership.coffee index 028639c741..1d2df70407 100644 --- a/services/web/public/coffee/main/group-members.coffee +++ b/services/web/public/coffee/main/user-membership.coffee @@ -1,9 +1,10 @@ define [ "base" ], (App) -> - App.controller "SubscriptionGroupMembersController", ($scope, queuedHttp) -> + App.controller "UserMembershipController", ($scope, queuedHttp) -> $scope.users = window.users $scope.groupSize = window.groupSize + $scope.paths = window.paths $scope.selectedUsers = [] $scope.inputs = @@ -22,7 +23,7 @@ define [ emails = parseEmails($scope.inputs.emails) for email in emails queuedHttp - .post("/subscription/invites", { + .post(paths.addMember, { email: email, _csrf: window.csrfToken }) @@ -34,10 +35,12 @@ define [ $scope.removeMembers = () -> for user in $scope.selectedUsers do (user) -> - if user.invite and !user._id? - url = "/subscription/invites/#{encodeURIComponent(user.email)}" + if paths.removeInvite and user.invite and !user._id? + url = "#{paths.removeInvite}/#{encodeURIComponent(user.email)}" + else if paths.removeMember and user._id? + url = "#{paths.removeMember}/#{user._id}" else - url = "/subscription/group/user/#{user._id}" + return queuedHttp({ method: "DELETE", url: url @@ -53,7 +56,7 @@ define [ $scope.updateSelectedUsers = () -> $scope.selectedUsers = $scope.users.filter (user) -> user.selected - App.controller "SubscriptionGroupMemberListItemController", ($scope) -> + App.controller "UserMembershipListItemController", ($scope) -> $scope.$watch "user.selected", (value) -> if value? $scope.updateSelectedUsers() diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee new file mode 100644 index 0000000000..3edb55e09a --- /dev/null +++ b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee @@ -0,0 +1,29 @@ +SandboxedModule = require('sandboxed-module') +should = require('chai').should() +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +modulePath = "../../../../app/js/Features/Institutions/InstitutionsLocator" +assert = require("chai").assert +ObjectId = require('mongoose').Types.ObjectId + +describe 'InstitutionsLocator', -> + beforeEach -> + @user = + _id: "5208dd34438842e2db333333" + @institution = + v1Id: 123 + managersIds: [ObjectId(), ObjectId()] + @Institution = + findOne: sinon.stub().yields(null, @institution) + @InstitutionsLocator = SandboxedModule.require modulePath, requires: + '../../models/Institution': Institution: @Institution + "logger-sharelatex": log:-> + + describe "finding managed institution", -> + + it "should query the database", (done) -> + @InstitutionsLocator.findManagedInstitution @user._id, (err, institution)=> + assertCalledWith(@Institution.findOne, { managerIds: @user._id }) + institution.should.equal @institution + done() diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee index a399496399..91706130f3 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee @@ -81,25 +81,6 @@ describe "SubscriptionGroupController", -> done() @Controller.removeUserFromGroup @req, res - describe "renderSubscriptionGroupAdminPage", -> - it "should redirect you if you don't have a group account", (done)-> - @subscription.groupPlan = false - - res = - redirect : (path)=> - path.should.equal("/user/subscription") - done() - @Controller.renderSubscriptionGroupAdminPage @req, res - - it "should redirect you don't have a subscription", (done)-> - @SubscriptionLocator.getUsersSubscription = sinon.stub().callsArgWith(1) - - res = - redirect : (path)=> - path.should.equal("/user/subscription") - done() - @Controller.renderSubscriptionGroupAdminPage @req, res - describe "exportGroupCsv", -> beforeEach -> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index 78f6ac3805..0f2290f014 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -157,52 +157,6 @@ describe "SubscriptionGroupHandler", -> { $pull: { member_ids: @oldId } } ).should.equal true - describe "getPopulatedListOfMembers", -> - beforeEach -> - @subscription = {} - @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription) - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - - it "should locate the subscription", (done)-> - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - @Handler.getPopulatedListOfMembers @subscriptionId, (err, users)=> - @SubscriptionLocator.getSubscription.calledWith(@subscriptionId).should.equal true - done() - - it "should get the users by id", (done)-> - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - @subscription.member_ids = ["1234", "342432", "312312"] - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - @UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true - @UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true - @UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true - users.length.should.equal @subscription.member_ids.length - done() - - it "should just return the id if the user can not be found as they may have deleted their account", (done)-> - @UserGetter.getUser.callsArgWith(1) - @subscription.member_ids = ["1234", "342432", "312312"] - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - assert.deepEqual users[0], {_id:@subscription.member_ids[0]} - assert.deepEqual users[1], {_id:@subscription.member_ids[1]} - assert.deepEqual users[2], {_id:@subscription.member_ids[2]} - done() - - it "should return any invited users", (done) -> - @subscription.invited_emails = [ "jo@example.com" ] - - @subscription.teamInvites = [ - { email: "charlie@example.com" } - ] - - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - users[0].email.should.equal "jo@example.com" - users[0].invite.should.equal true - users[1].email.should.equal "charlie@example.com" - users[1].invite.should.equal true - users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length - done() - describe "isUserPartOfGroup", -> beforeEach -> @subscription_id = "123ed13123" diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee index a66d7f5dec..1a67a183b4 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee @@ -17,6 +17,7 @@ describe "SubscriptionUpdater", -> _id: @adminuser_id = "5208dd34438843e2db000007" @otherUserId = "5208dd34438842e2db000005" @allUserIds = ["13213", "dsadas", "djsaiud89"] + @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com' @subscription = subscription = _id: "111111111111111111111111" admin_id: @adminUser._id @@ -67,6 +68,7 @@ describe "SubscriptionUpdater", -> getUsers: (memberIds, projection, callback) -> users = memberIds.map (id) -> { _id: id } callback(null, users) + getUserOrUserStubById: sinon.stub() @ReferalFeatures = getBonusFeatures: sinon.stub().callsArgWith(1) @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}} @@ -190,6 +192,7 @@ describe "SubscriptionUpdater", -> describe "removeUserFromGroup", -> beforeEach -> @FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1) + @UserGetter.getUserOrUserStubById.yields(null, {}, false) it "should pull the users id from the group", (done)-> @SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, => @@ -205,6 +208,12 @@ describe "SubscriptionUpdater", -> @FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true done() + it "should not update features for user stubs", (done)-> + @UserGetter.getUserOrUserStubById.yields(null, {}, true) + @SubscriptionUpdater.removeUserFromGroup @subscription._id, @userStub._id, => + @FeaturesUpdater.refreshFeatures.called.should.equal false + done() + describe "deleteSubscription", -> beforeEach (done) -> @subscription_id = ObjectId().toString() diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee new file mode 100644 index 0000000000..f890abfefa --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee @@ -0,0 +1,95 @@ +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +chai = require('chai') +should = chai.should() +assert = chai.assert +expect = require('chai').expect +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipController.js" +SandboxedModule = require('sandboxed-module') +MockRequest = require "../helpers/MockRequest" +MockResponse = require "../helpers/MockResponse" + +describe "UserMembershipController", -> + beforeEach -> + @req = new MockRequest() + @user = _id: 'mock-user-id' + @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' + @subscription = { _id: 'mock-subscription-id'} + @users = [{ _id: 'mock-member-id-1' }, { _id: 'mock-member-id-2' }] + + @AuthenticationController = + getLoggedInUserId: sinon.stub().returns(@user._id) + @UserMembershipHandler = + getEntity: sinon.stub().yields(null, @subscription) + getUsers: sinon.stub().yields(null, @users) + addUser: sinon.stub().yields(null, @newUser) + removeUser: sinon.stub().yields(null) + @UserMembershipController = SandboxedModule.require modulePath, requires: + '../Authentication/AuthenticationController': @AuthenticationController + './UserMembershipHandler': @UserMembershipHandler + "logger-sharelatex": + log: -> + err: -> + + describe 'index', -> + it 'get entity', (done) -> + @UserMembershipController.index 'group', @req, render: () => + sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'group', @user._id) + done() + + it 'get users', (done) -> + @UserMembershipController.index 'group', @req, render: () => + sinon.assert.calledWith(@UserMembershipHandler.getUsers, 'group', @subscription) + done() + + it 'render group view', (done) -> + @UserMembershipController.index 'group', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.entity).to.deep.equal @subscription + expect(viewParams.users).to.deep.equal @users + expect(viewParams.translations.title).to.equal 'group_account' + expect(viewParams.paths.addMember).to.equal '/subscription/invites' + done() + + it 'render group managers view', (done) -> + @UserMembershipController.index 'groupManagers', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.translations.title).to.equal 'group_managers' + expect(viewParams.paths.exportMembers).to.be.undefined + done() + + it 'render institution view', (done) -> + @UserMembershipController.index 'institution', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.translations.title).to.equal 'institution_managers' + expect(viewParams.paths.exportMembers).to.be.undefined + done() + + describe 'add', -> + beforeEach -> + @req.body.email = @newUser.email + + it 'get entity', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: () => + sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'groupManagers', @user._id) + done() + + it 'add user', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: () => + sinon.assert.calledWith(@UserMembershipHandler.addUser, 'groupManagers', @subscription, @newUser.email) + done() + + it 'return user object', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: (payload) => + payload.user.should.equal @newUser + done() + + describe 'remove', -> + beforeEach -> + @req.params.userId = @newUser._id + + it 'remove user', (done) -> + @UserMembershipController.remove 'groupManagers', @req, send: () => + sinon.assert.calledWith(@UserMembershipHandler.removeUser, 'groupManagers', @subscription, @newUser._id) + done() diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee new file mode 100644 index 0000000000..23598a82a1 --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee @@ -0,0 +1,172 @@ +chai = require('chai') +should = chai.should() +expect = require('chai').expect +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +ObjectId = require("../../../../app/js/infrastructure/mongojs").ObjectId +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipHandler" +SandboxedModule = require("sandboxed-module") +Errors = require("../../../../app/js/Features/Errors/Errors") + +describe 'UserMembershipHandler', -> + beforeEach -> + @user = _id: 'mock-user-id' + @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' + @subscription = + _id: 'mock-subscription-id' + groupPlan: true + membersLimit: 10 + member_ids: [ObjectId(), ObjectId()] + manager_ids: [ObjectId()] + invited_emails: ['mock-email-1@foo.com'] + teamInvites: [{ email: 'mock-email-1@bar.com' }] + update: sinon.stub().yields(null) + @institution = + _id: 'mock-institution-id' + v1Id: 123 + managerIds: [ObjectId(), ObjectId(), ObjectId()] + update: sinon.stub().yields(null) + + @SubscriptionLocator = + findManagedSubscription: sinon.stub().yields(null, @subscription) + @InstitutionsLocator = + findManagedInstitution: sinon.stub().yields(null, @institution) + @UserMembershipViewModel = + buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'}) + build: sinon.stub().returns(@newUser) + @UserGetter = + getUserByAnyEmail: sinon.stub().yields(null, @newUser) + @UserMembershipHandler = SandboxedModule.require modulePath, requires: + '../Subscription/SubscriptionLocator': @SubscriptionLocator + '../Institutions/InstitutionsLocator': @InstitutionsLocator + './UserMembershipViewModel': @UserMembershipViewModel + '../User/UserGetter': @UserGetter + '../Errors/Errors': Errors + 'logger-sharelatex': + log: -> + err: -> + + describe 'getEntty', -> + it 'validate type', (done) -> + @UserMembershipHandler.getEntity 'foo', null, (error) -> + should.exist(error) + expect(error.message).to.match /No such entity/ + done() + + describe 'group subscriptions', -> + it 'get subscription', (done) -> + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) => + should.not.exist(error) + assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id) + expect(subscription).to.equal @subscription + expect(subscription.membersLimit).to.equal 10 + done() + + it 'check subscription is a group', (done) -> + @SubscriptionLocator.findManagedSubscription.yields(null, { groupPlan: false }) + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) -> + should.exist(error) + done() + + it 'handle error', (done) -> + @SubscriptionLocator.findManagedSubscription.yields(new Error('some error')) + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) => + should.exist(error) + done() + + describe 'group managers', -> + it 'has no members limit', (done) -> + @UserMembershipHandler.getEntity 'groupManagers', @user._id, (error, subscription) => + should.not.exist(error) + assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id) + expect(subscription.membersLimit).to.equal null + done() + + describe 'institutions', -> + it 'get institution', (done) -> + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.not.exist(error) + assertCalledWith(@InstitutionsLocator.findManagedInstitution, @user._id) + expect(institution).to.equal @institution + done() + + it 'handle institution not found', (done) -> + @InstitutionsLocator.findManagedInstitution.yields(null, null) + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.exist(error) + expect(error).to.be.an.instanceof(Errors.NotFoundError) + done() + + it 'handle errors', (done) -> + @InstitutionsLocator.findManagedInstitution.yields(new Error('nope')) + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.exist(error) + expect(error).to.not.be.an.instanceof(Errors.NotFoundError) + done() + + describe 'getUsers', -> + describe 'group', -> + it 'build view model for all users', (done) -> + @UserMembershipHandler.getUsers 'group', @subscription, (error, users) => + expectedCallcount = + @subscription.member_ids.length + + @subscription.invited_emails.length + + @subscription.teamInvites.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'group mamagers', -> + it 'build view model for all managers', (done) -> + @UserMembershipHandler.getUsers 'groupManagers', @subscription, (error, users) => + expectedCallcount = @subscription.manager_ids.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'institution', -> + it 'build view model for all managers', (done) -> + @UserMembershipHandler.getUsers 'institution', @institution, (error, users) => + expectedCallcount = @institution.managerIds.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'addUser', -> + beforeEach -> + @email = @newUser.email + + describe 'group', -> + it 'fails', (done) -> + @UserMembershipHandler.addUser 'group', @subscription, @email, (error) => + expect(error).to.exist + done() + + describe 'institution', -> + it 'get user', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + assertCalledWith(@UserGetter.getUserByAnyEmail, @email) + done() + + it 'handle user not found', (done) -> + @UserGetter.getUserByAnyEmail.yields(null, null) + @UserMembershipHandler.addUser 'institution', @institution, @email, (error) => + expect(error).to.exist + expect(error).to.be.an.instanceof(Errors.NotFoundError) + done() + + it 'add user to institution', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + assertCalledWith(@institution.update, { $addToSet: managerIds: @newUser._id }) + done() + + it 'return user view', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + user.should.equal @newUser + done() + + describe 'removeUser', -> + describe 'institution', -> + it 'remove user from institution', (done) -> + @UserMembershipHandler.removeUser 'institution', @institution, @newUser._id, (error, user) => + lastCall = @institution.update.lastCall + assertCalledWith(@institution.update, { $pull: managerIds: @newUser._id }) + done() diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee new file mode 100644 index 0000000000..7dc7dbeff0 --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee @@ -0,0 +1,83 @@ +chai = require('chai') +should = chai.should() +expect = require('chai').expect +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +mongojs = require('mongojs') +ObjectId = mongojs.ObjectId +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipViewModel" +SandboxedModule = require("sandboxed-module") + +describe 'UserMembershipViewModel', -> + beforeEach -> + @UserGetter = + getUserOrUserStubById: sinon.stub() + @UserMembershipViewModel = SandboxedModule.require modulePath, requires: + 'mongojs': mongojs + '../User/UserGetter': @UserGetter + @email = 'mock-email@bar.com' + @user = _id: 'mock-user-id', email: 'mock-email@baz.com', first_name: 'Name' + @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com' + + describe 'build', -> + it 'build email', -> + viewModel = @UserMembershipViewModel.build(@email) + expect(viewModel).to.deep.equal + email: @email + invite: true + first_name: null + last_name: null + _id: null + + it 'build user', -> + viewModel = @UserMembershipViewModel.build(@user) + expect(viewModel._id).to.equal @user._id + expect(viewModel.email).to.equal @user.email + expect(viewModel.invite).to.equal false + + describe 'build async', -> + beforeEach -> + @UserMembershipViewModel.build = sinon.stub() + + it 'build email', (done) -> + @UserMembershipViewModel.buildAsync @email, (error, viewModel) => + assertCalledWith(@UserMembershipViewModel.build, @email) + done() + + it 'build user', (done) -> + @UserMembershipViewModel.buildAsync @user, (error, viewModel) => + assertCalledWith(@UserMembershipViewModel.build, @user) + done() + + it 'build user id', (done) -> + @UserGetter.getUserOrUserStubById.yields(null, @user, false) + @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal @user._id + expect(viewModel.email).to.equal @user.email + expect(viewModel.first_name).to.equal @user.first_name + expect(viewModel.invite).to.equal false + should.exist(viewModel.email) + done() + + it 'build user stub id', (done) -> + @UserGetter.getUserOrUserStubById.yields(null, @userStub, true) + @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal @userStub._id + expect(viewModel.email).to.equal @userStub.email + expect(viewModel.invite).to.equal true + done() + + it 'build user id with error', (done) -> + @UserGetter.getUserOrUserStubById.yields(new Error('nope')) + userId = ObjectId() + @UserMembershipViewModel.buildAsync userId, (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal userId.toString() + should.not.exist(viewModel.email) + done()