diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index 8b672bc4c5..9b975140f9 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -79,7 +79,7 @@ Thank you #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: (opts) -> + compiledTemplate: (opts) -> SingleCTAEmailBody({ title: "Password Reset" greeting: "Hi," @@ -104,7 +104,7 @@ Thank you #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: (opts) -> + compiledTemplate: (opts) -> SingleCTAEmailBody({ title: "#{ opts.project.name } – shared by #{ opts.owner.email }" greeting: "Hi," @@ -112,12 +112,38 @@ Thank you secondaryMessage: null ctaText: "View project" ctaURL: opts.inviteUrl - gmailGoToAction: + gmailGoToAction: target: opts.inviteUrl name: "View project" description: "Join #{ opts.project.name } at ShareLaTeX" }) + +templates.verifyEmailToJoinTeam = + subject: _.template "<%= inviterName %> has invited you to join a #{settings.appName} team" + layout: BaseWithHeaderEmailLayout + type:"notification" + plainTextTemplate: _.template """ + +Hi, please verify your email to join the team and get your free premium account. + +Click this link to verify now: <%= acceptInviteUrl %> + +Thank You + +#{settings.appName} - <%= siteUrl %> +""" + compiledTemplate: (opts) -> + SingleCTAEmailBody({ + title: "#{opts.inviterName} has invited you to join a #{settings.appName} team" + greeting: "Hi," + message: "please verify your email to join the team and get your free premium account" + secondaryMessage: null + ctaText: "Verify now" + ctaURL: opts.acceptInviteUrl + gmailGoToAction: null + }) + templates.completeJoinGroupAccount = subject: _.template "Verify Email to join <%= group_name %> group" layout: BaseWithHeaderEmailLayout @@ -131,7 +157,7 @@ Thank You #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: (opts) -> + compiledTemplate: (opts) -> SingleCTAEmailBody({ title: "Verify Email to join #{ opts.group_name } group" greeting: "Hi," @@ -154,7 +180,7 @@ This is a test email sent from ShareLaTeX. #{settings.appName} - <%= siteUrl %> """ - compiledTemplate: (opts) -> + compiledTemplate: (opts) -> SingleCTAEmailBody({ title: "A Test Email from ShareLaTeX" greeting: "Hi," diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index 5b7c2e0740..3ac9ae0181 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -6,6 +6,7 @@ UserLocator = require("../User/UserLocator") LimitationsManager = require("./LimitationsManager") logger = require("logger-sharelatex") OneTimeTokenHandler = require("../Security/OneTimeTokenHandler") +TeamInvitesHandler = require("./TeamInvitesHandler") EmailHandler = require("../Email/EmailHandler") settings = require("settings-sharelatex") NotificationsBuilder = require("../Notifications/NotificationsBuilder") @@ -39,15 +40,24 @@ module.exports = SubscriptionGroupHandler = removeUserFromGroup: (adminUser_id, userToRemove_id, callback)-> SubscriptionUpdater.removeUserFromGroup adminUser_id, userToRemove_id, callback - + removeEmailInviteFromGroup: (adminUser_id, email, callback) -> SubscriptionUpdater.removeEmailInviteFromGroup adminUser_id, email, callback getPopulatedListOfMembers: (adminUser_id, callback)-> - SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)-> + SubscriptionLocator.getUsersSubscription adminUser_id, (err, subscription)-> + return callback(err) if err? + users = [] for email in subscription.invited_emails or [] users.push buildEmailInviteViewModel(email) + + TeamInvitesHandler.getInvites subscription.id, (err, invites) -> + return callback(err) if err? + + for invite in invites or [] + users.push buildEmailInviteViewModel(invite.email) + jobs = _.map subscription.member_ids, (user_id)-> return (cb)-> UserLocator.findById user_id, (err, user)-> @@ -111,7 +121,7 @@ module.exports = SubscriptionGroupHandler = async.series jobs, callback buildUserViewModel = (user)-> - u = + u = email: user.email first_name: user.first_name last_name: user.last_name @@ -123,4 +133,4 @@ buildEmailInviteViewModel = (email) -> return { email: email holdingAccount: true - } \ No newline at end of file + } diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee index 8c5f631e12..b0b13f0184 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee @@ -1,6 +1,7 @@ AuthenticationController = require('../Authentication/AuthenticationController') SubscriptionController = require('./SubscriptionController') SubscriptionGroupController = require './SubscriptionGroupController' +TeamInvitesController = require './TeamInvitesController' Settings = require "settings-sharelatex" module.exports = @@ -13,8 +14,7 @@ module.exports = webRouter.get '/user/subscription/custom_account', AuthenticationController.requireLogin(), SubscriptionController.userCustomSubscriptionPage - - webRouter.get '/user/subscription/new', AuthenticationController.requireLogin(), SubscriptionController.paymentPage + webRouter.get '/user/subscription/new', AuthenticationController.requireLogin(), SubscriptionController.paymentPage webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription @@ -26,6 +26,15 @@ module.exports = webRouter.delete '/subscription/group/email/:email', AuthenticationController.requireLogin(), SubscriptionGroupController.removeEmailInviteFromGroup webRouter.delete '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.removeSelfFromGroup + # Team invites + webRouter.post '/subscription/invites', AuthenticationController.requireLogin(), + TeamInvitesController.createInvite + webRouter.get '/subscription/invites/:token/', AuthenticationController.requireLogin(), + TeamInvitesController.viewInvite + webRouter.put '/subscription/invites/:token/', AuthenticationController.requireLogin(), + TeamInvitesController.acceptInvite + webRouter.delete '/subscription/invites/:token/', AuthenticationController.requireLogin(), + TeamInvitesController.revokeInvite webRouter.get '/user/subscription/:subscription_id/group/invited', AuthenticationController.requireLogin(), SubscriptionGroupController.renderGroupInvitePage webRouter.post '/user/subscription/:subscription_id/group/begin-join', AuthenticationController.requireLogin(), SubscriptionGroupController.beginJoinGroup @@ -48,4 +57,3 @@ module.exports = # Currently used in acceptance tests only, as a way to trigger the syncing logic publicApiRouter.post "/user/:user_id/features/sync", AuthenticationController.httpAuth, SubscriptionController.refreshUserFeatures - diff --git a/services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee b/services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee new file mode 100644 index 0000000000..a073c448e2 --- /dev/null +++ b/services/web/app/coffee/Features/Subscription/TeamInvitesController.coffee @@ -0,0 +1,30 @@ +logger = require("logger-sharelatex") +TeamInvitesHandler = require('./TeamInvitesHandler') +AuthenticationController = require("../Authentication/AuthenticationController") + +module.exports = + createInvite: (req, res, next) -> + adminUserId = AuthenticationController.getLoggedInUserId(req) + email = req.body.email + + TeamInvitesHandler.createInvite adminUserId, email, (err, invite) -> + next(err) if err? + inviteView = { user: + { email: invite.email, sentAt: invite.sentAt, holdingAccount: true } + } + res.json inviteView + + viewInvite: (req, res) -> + token = request.params.token + + TeamInvitesHandler.getInvite token, (err, invite) -> + next(err) if err? + + res.render "referal/bonus", + title: "bonus_please_recommend_us" + refered_users: refered_users + refered_user_count: (refered_users or []).length + + acceptInvite: (req, res) -> + + revokeInvite: (req, res) -> diff --git a/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee new file mode 100644 index 0000000000..2d170aae93 --- /dev/null +++ b/services/web/app/coffee/Features/Subscription/TeamInvitesHandler.coffee @@ -0,0 +1,44 @@ +logger = require("logger-sharelatex") +crypto = require("crypto") + +settings = require("settings-sharelatex") + +UserLocator = require("../User/UserLocator") +SubscriptionLocator = require("./SubscriptionLocator") +TeamInvite = require("../../models/TeamInvite").TeamInvite +EmailHandler = require("../Email/EmailHandler") + +module.exports = TeamInvitesHandler = + + getInvites: (subscriptionId, callback) -> + TeamInvite.find(subscriptionId: subscriptionId, callback) + + createInvite: (adminUserId, email, callback) -> + + UserLocator.findById adminUserId, (error, groupAdmin) -> + SubscriptionLocator.getUsersSubscription adminUserId, (error, subscription) -> + return callback(error) if error? + + inviterName = "#{groupAdmin.first_name} #{groupAdmin.last_name}" + token = crypto.randomBytes(32).toString("hex") + + TeamInvite.create { + subscriptionId: subscription.id, + email: email, + token: token, + sentAt: new Date(), + }, (error, invite) -> + return callback(error) if error? + opts = + to : email + inviterName: inviterName + acceptInviteUrl: "#{settings.siteUrl}/subscription/invites/#{token}/" + EmailHandler.sendEmail "verifyEmailToJoinTeam", opts, (error) -> + return callback(error, invite) + + getInvite: (token, callback) -> + + + acceptInvite: (userId, token, callback) -> + + revokeInvite: (token, callback) -> diff --git a/services/web/app/coffee/models/TeamInvite.coffee b/services/web/app/coffee/models/TeamInvite.coffee new file mode 100644 index 0000000000..ba0d0dc288 --- /dev/null +++ b/services/web/app/coffee/models/TeamInvite.coffee @@ -0,0 +1,16 @@ +mongoose = require 'mongoose' +Settings = require 'settings-sharelatex' + +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +TeamInviteSchema = new Schema + subscriptionId : { type: ObjectId, ref: 'Subscription', required: true } + email : { type: String, required: true } + token : { type: String, required: true } + sentAt : { type: Date, required: true } + + +mongoose.model 'TeamInvite', TeamInviteSchema +exports.TeamInvite = mongoose.model 'TeamInvite' +exports.TeamInviteSchema = TeamInviteSchema diff --git a/services/web/app/views/subscriptions/group/team_invite.pug b/services/web/app/views/subscriptions/group/team_invite.pug new file mode 100644 index 0000000000..6d7e962ccc --- /dev/null +++ b/services/web/app/views/subscriptions/group/team_invite.pug @@ -0,0 +1,55 @@ +extends ../../layout + +block scripts + script(type='text/javascript'). + window.teamId = '#{teamId}' + window.hasPersonalSubscription = #{hasPersonalSubscription} + window.inviteToken = #{inviteToken} + +block content + .content.content-alt + .container + .row + .col-md-8.col-md-offset-2 + -if (query.expired) + .alert.alert-warning #{translate("email_link_expired")} + + .row + div   + .row + .col-md-8.col-md-offset-2(ng-cloak) + .card(ng-controller="TeamInviteController") + .page-header + h1.text-centered #{translate("you_are_invited_to_group", {teamName: teamName})} + + div(ng-show="view =='personalSubscription'").row.text-centered + div #{translate("cancel_personal_subscription_first")} + .row + .col-md-12   + .row + .col-md-12 + a.btn.btn.btn-default(ng-click="keepPersonalSubscription()", ng-disabled="inflight") #{translate("not_now")} + span   + a.btn.btn.btn-primary(ng-click="cancelPersonalSubscription()", ng-disabled="inflight") #{translate("cancel_your_subscription")} + + div(ng-show="view =='teamInvite'").row.text-centered + .row + .col-md-12 #{translate("group_provides_you_with_premium_account", {teamName: teamName})} + .row + .col-md-12   + .row + .col-md-12 + .text-center + a.btn.btn-default(href="/project") #{translate("not_now")} + span   + a.btn.btn.btn-primary(ng-click="joinTeam()", ng-disabled="inflight") #{translate("verify_email_address")} + + + span(ng-show="view =='requestSent'").row.text-centered.text-center + .row + .col-md-12 #{translate("check_email_to_complete_the_upgrade")} + .row + .col-md-12   + .row + .col-md-12 + a.btn.btn.btn-primary(href="/project") #{translate("done")} diff --git a/services/web/public/coffee/main/group-members.coffee b/services/web/public/coffee/main/group-members.coffee index 813ad4c768..94300f8ce5 100644 --- a/services/web/public/coffee/main/group-members.coffee +++ b/services/web/public/coffee/main/group-members.coffee @@ -22,7 +22,7 @@ define [ emails = parseEmails($scope.inputs.emails) for email in emails queuedHttp - .post("/subscription/group/user", { + .post("/subscription/invites", { email: email, _csrf: window.csrfToken }) @@ -56,4 +56,4 @@ define [ App.controller "SubscriptionGroupMemberListItemController", ($scope) -> $scope.$watch "user.selected", (value) -> if value? - $scope.updateSelectedUsers() \ No newline at end of file + $scope.updateSelectedUsers() diff --git a/services/web/public/coffee/main/subscription/team-invite-controller.coffee b/services/web/public/coffee/main/subscription/team-invite-controller.coffee new file mode 100644 index 0000000000..ab6b42c658 --- /dev/null +++ b/services/web/public/coffee/main/subscription/team-invite-controller.coffee @@ -0,0 +1,35 @@ +define [ + "base" +], (App) -> + App.controller "TeamInviteController", ($scope, $http) -> + + $scope.inflight = false + + if hasPersonalSubscription + $scope.view = "personalSubscription" + else + $scope.view = "teamInvite" + + $scope.keepPersonalSubscription = -> + $scope.view = "teamInvite" + + $scope.cancelPersonalSubscription = -> + $scope.inflight = true + request = $http.post "/user/subscription/cancel", {_csrf:window.csrfToken} + request.then ()-> + $scope.inflight = false + $scope.view = "teamInvite" + request.catch ()-> + console.log "the request failed" + + $scope.joinTeam = -> + $scope.view = "requestSent" + $scope.inflight = true + request = $http.put "/subscription/invites/:token/", {_csrf:window.csrfToken} + request.then (response)-> + { status } = response + $scope.inflight = false + if status != 200 # assume request worked + $scope.requestSent = false + request.catch ()-> + console.log "the request failed"